aboutsummaryrefslogtreecommitdiffstats
path: root/bot/exts
diff options
context:
space:
mode:
Diffstat (limited to 'bot/exts')
-rw-r--r--bot/exts/__init__.py35
-rw-r--r--bot/exts/christmas/__init__.py0
-rw-r--r--bot/exts/christmas/adventofcode.py743
-rw-r--r--bot/exts/christmas/hanukkah_embed.py113
-rw-r--r--bot/exts/easter/__init__.py0
-rw-r--r--bot/exts/easter/april_fools_vids.py38
-rw-r--r--bot/exts/easter/avatar_easterifier.py128
-rw-r--r--bot/exts/easter/bunny_name_generator.py92
-rw-r--r--bot/exts/easter/conversationstarters.py28
-rw-r--r--bot/exts/easter/easter_riddle.py100
-rw-r--r--bot/exts/easter/egg_decorating.py118
-rw-r--r--bot/exts/easter/egg_facts.py60
-rw-r--r--bot/exts/easter/egghead_quiz.py119
-rw-r--r--bot/exts/easter/traditions.py30
-rw-r--r--bot/exts/evergreen/8bitify.py54
-rw-r--r--bot/exts/evergreen/__init__.py0
-rw-r--r--bot/exts/evergreen/battleship.py443
-rw-r--r--bot/exts/evergreen/bookmark.py64
-rw-r--r--bot/exts/evergreen/branding.py543
-rw-r--r--bot/exts/evergreen/error_handler.py129
-rw-r--r--bot/exts/evergreen/fun.py147
-rw-r--r--bot/exts/evergreen/game.py424
-rw-r--r--bot/exts/evergreen/help.py552
-rw-r--r--bot/exts/evergreen/issues.py76
-rw-r--r--bot/exts/evergreen/magic_8ball.py31
-rw-r--r--bot/exts/evergreen/minesweeper.py284
-rw-r--r--bot/exts/evergreen/movie.py198
-rw-r--r--bot/exts/evergreen/recommend_game.py50
-rw-r--r--bot/exts/evergreen/reddit.py128
-rw-r--r--bot/exts/evergreen/showprojects.py33
-rw-r--r--bot/exts/evergreen/snakes/__init__.py12
-rw-r--r--bot/exts/evergreen/snakes/converter.py85
-rw-r--r--bot/exts/evergreen/snakes/snakes_cog.py1149
-rw-r--r--bot/exts/evergreen/snakes/utils.py716
-rw-r--r--bot/exts/evergreen/space.py240
-rw-r--r--bot/exts/evergreen/speedrun.py27
-rw-r--r--bot/exts/evergreen/trivia_quiz.py302
-rw-r--r--bot/exts/evergreen/uptime.py33
-rw-r--r--bot/exts/halloween/8ball.py33
-rw-r--r--bot/exts/halloween/__init__.py0
-rw-r--r--bot/exts/halloween/candy_collection.py224
-rw-r--r--bot/exts/halloween/hacktober-issue-finder.py110
-rw-r--r--bot/exts/halloween/hacktoberstats.py341
-rw-r--r--bot/exts/halloween/halloween_facts.py58
-rw-r--r--bot/exts/halloween/halloweenify.py51
-rw-r--r--bot/exts/halloween/monsterbio.py55
-rw-r--r--bot/exts/halloween/monstersurvey.py205
-rw-r--r--bot/exts/halloween/scarymovie.py131
-rw-r--r--bot/exts/halloween/spookyavatar.py52
-rw-r--r--bot/exts/halloween/spookygif.py38
-rw-r--r--bot/exts/halloween/spookyrating.py66
-rw-r--r--bot/exts/halloween/spookyreact.py75
-rw-r--r--bot/exts/halloween/spookysound.py47
-rw-r--r--bot/exts/halloween/timeleft.py59
-rw-r--r--bot/exts/pride/__init__.py0
-rw-r--r--bot/exts/pride/drag_queen_name.py32
-rw-r--r--bot/exts/pride/pride_anthem.py57
-rw-r--r--bot/exts/pride/pride_avatar.py144
-rw-r--r--bot/exts/pride/pride_facts.py106
-rw-r--r--bot/exts/valentines/__init__.py0
-rw-r--r--bot/exts/valentines/be_my_valentine.py236
-rw-r--r--bot/exts/valentines/lovecalculator.py103
-rw-r--r--bot/exts/valentines/movie_generator.py62
-rw-r--r--bot/exts/valentines/myvalenstate.py86
-rw-r--r--bot/exts/valentines/pickuplines.py44
-rw-r--r--bot/exts/valentines/savethedate.py41
-rw-r--r--bot/exts/valentines/valentine_zodiac.py57
-rw-r--r--bot/exts/valentines/whoisvalentine.py52
68 files changed, 9889 insertions, 0 deletions
diff --git a/bot/exts/__init__.py b/bot/exts/__init__.py
new file mode 100644
index 00000000..25deb9af
--- /dev/null
+++ b/bot/exts/__init__.py
@@ -0,0 +1,35 @@
+import logging
+import pkgutil
+from pathlib import Path
+from typing import Iterator
+
+__all__ = ("get_package_names", "walk_extensions")
+
+log = logging.getLogger(__name__)
+
+
+def get_package_names() -> Iterator[str]:
+ """Iterate names of all packages located in /bot/exts/."""
+ for package in pkgutil.iter_modules(__path__):
+ if package.ispkg:
+ yield package.name
+
+
+def walk_extensions() -> Iterator[str]:
+ """
+ Iterate dot-separated paths to all extensions.
+
+ The strings are formatted in a way such that the bot's `load_extension`
+ method can take them. Use this to load all available extensions.
+
+ This intentionally doesn't make use of pkgutil's `walk_packages`, as we only
+ want to build paths to extensions - not recursively all modules. For some
+ extensions, the `setup` function is in the package's __init__ file, while
+ modules nested under the package are only helpers. Constructing the paths
+ ourselves serves our purpose better.
+ """
+ base_path = Path(__path__[0])
+
+ for package in get_package_names():
+ for extension in pkgutil.iter_modules([base_path.joinpath(package)]):
+ yield f"bot.exts.{package}.{extension.name}"
diff --git a/bot/exts/christmas/__init__.py b/bot/exts/christmas/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bot/exts/christmas/__init__.py
diff --git a/bot/exts/christmas/adventofcode.py b/bot/exts/christmas/adventofcode.py
new file mode 100644
index 00000000..cc3923c8
--- /dev/null
+++ b/bot/exts/christmas/adventofcode.py
@@ -0,0 +1,743 @@
+import asyncio
+import json
+import logging
+import math
+import re
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import List, Tuple
+
+import aiohttp
+import discord
+from bs4 import BeautifulSoup
+from discord.ext import commands
+from pytz import timezone
+
+from bot.constants import AdventOfCode as AocConfig, Channels, Colours, Emojis, Month, Tokens, WHITELISTED_CHANNELS
+from bot.utils import unlocked_role
+from bot.utils.decorators import in_month, override_in_channel
+
+log = logging.getLogger(__name__)
+
+AOC_REQUEST_HEADER = {"user-agent": "PythonDiscord AoC Event Bot"}
+AOC_SESSION_COOKIE = {"session": Tokens.aoc_session_cookie}
+
+EST = timezone("EST")
+COUNTDOWN_STEP = 60 * 5
+
+AOC_WHITELIST = WHITELISTED_CHANNELS + (Channels.advent_of_code,)
+
+
+def is_in_advent() -> bool:
+ """Utility function to check if we are between December 1st and December 25th."""
+ # Run the code from the 1st to the 24th
+ return datetime.now(EST).day in range(1, 25) and datetime.now(EST).month == 12
+
+
+def time_left_to_aoc_midnight() -> Tuple[datetime, timedelta]:
+ """Calculates the amount of time left until midnight in UTC-5 (Advent of Code maintainer timezone)."""
+ # Change all time properties back to 00:00
+ todays_midnight = datetime.now(EST).replace(microsecond=0,
+ second=0,
+ minute=0,
+ hour=0)
+
+ # We want tomorrow so add a day on
+ tomorrow = todays_midnight + timedelta(days=1)
+
+ # Calculate the timedelta between the current time and midnight
+ return tomorrow, tomorrow - datetime.now(EST)
+
+
+async def countdown_status(bot: commands.Bot) -> None:
+ """Set the playing status of the bot to the minutes & hours left until the next day's challenge."""
+ while is_in_advent():
+ _, time_left = time_left_to_aoc_midnight()
+
+ aligned_seconds = int(math.ceil(time_left.seconds / COUNTDOWN_STEP)) * COUNTDOWN_STEP
+ hours, minutes = aligned_seconds // 3600, aligned_seconds // 60 % 60
+
+ if aligned_seconds == 0:
+ playing = f"right now!"
+ elif aligned_seconds == COUNTDOWN_STEP:
+ playing = f"in less than {minutes} minutes"
+ elif hours == 0:
+ playing = f"in {minutes} minutes"
+ elif hours == 23:
+ playing = f"since {60 - minutes} minutes ago"
+ else:
+ playing = f"in {hours} hours and {minutes} minutes"
+
+ # Status will look like "Playing in 5 hours and 30 minutes"
+ await bot.change_presence(activity=discord.Game(playing))
+
+ # Sleep until next aligned time or a full step if already aligned
+ delay = time_left.seconds % COUNTDOWN_STEP or COUNTDOWN_STEP
+ await asyncio.sleep(delay)
+
+
+async def day_countdown(bot: commands.Bot) -> None:
+ """
+ Calculate the number of seconds left until the next day of Advent.
+
+ Once we have calculated this we should then sleep that number and when the time is reached, ping
+ the Advent of Code role notifying them that the new challenge is ready.
+ """
+ while is_in_advent():
+ tomorrow, time_left = time_left_to_aoc_midnight()
+
+ # Correct `time_left.seconds` for the sleep we have after unlocking the role (-5) and adding
+ # a second (+1) as the bot is consistently ~0.5 seconds early in announcing the puzzles.
+ await asyncio.sleep(time_left.seconds - 4)
+
+ channel = bot.get_channel(Channels.advent_of_code)
+
+ if not channel:
+ log.error("Could not find the AoC channel to send notification in")
+ break
+
+ aoc_role = channel.guild.get_role(AocConfig.role_id)
+ if not aoc_role:
+ log.error("Could not find the AoC role to announce the daily puzzle")
+ break
+
+ async with unlocked_role(aoc_role, delay=5):
+ puzzle_url = f"https://adventofcode.com/{AocConfig.year}/day/{tomorrow.day}"
+
+ # Check if the puzzle is already available to prevent our members from spamming
+ # the puzzle page before it's available by making a small HEAD request.
+ for retry in range(1, 5):
+ log.debug(f"Checking if the puzzle is already available (attempt {retry}/4)")
+ async with bot.http_session.head(puzzle_url, raise_for_status=False) as resp:
+ if resp.status == 200:
+ log.debug("Puzzle is available; let's send an announcement message.")
+ break
+ log.debug(f"The puzzle is not yet available (status={resp.status})")
+ await asyncio.sleep(10)
+ else:
+ log.error("The puzzle does does not appear to be available at this time, canceling announcement")
+ break
+
+ await channel.send(
+ f"{aoc_role.mention} Good morning! Day {tomorrow.day} is ready to be attempted. "
+ f"View it online now at {puzzle_url}. Good luck!"
+ )
+
+ # Wait a couple minutes so that if our sleep didn't sleep enough
+ # time we don't end up announcing twice.
+ await asyncio.sleep(120)
+
+
+class AdventOfCode(commands.Cog):
+ """Advent of Code festivities! Ho Ho Ho!"""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ self._base_url = f"https://adventofcode.com/{AocConfig.year}"
+ self.global_leaderboard_url = f"https://adventofcode.com/{AocConfig.year}/leaderboard"
+ self.private_leaderboard_url = f"{self._base_url}/leaderboard/private/view/{AocConfig.leaderboard_id}"
+
+ self.about_aoc_filepath = Path("./bot/resources/advent_of_code/about.json")
+ self.cached_about_aoc = self._build_about_embed()
+
+ self.cached_global_leaderboard = None
+ self.cached_private_leaderboard = None
+
+ self.countdown_task = None
+ self.status_task = None
+
+ countdown_coro = day_countdown(self.bot)
+ self.countdown_task = self.bot.loop.create_task(countdown_coro)
+
+ status_coro = countdown_status(self.bot)
+ self.status_task = self.bot.loop.create_task(status_coro)
+
+ @in_month(Month.DECEMBER)
+ @commands.group(name="adventofcode", aliases=("aoc",))
+ @override_in_channel(AOC_WHITELIST)
+ async def adventofcode_group(self, ctx: commands.Context) -> None:
+ """All of the Advent of Code commands."""
+ if not ctx.invoked_subcommand:
+ await ctx.send_help(ctx.command)
+
+ @adventofcode_group.command(
+ name="subscribe",
+ aliases=("sub", "notifications", "notify", "notifs"),
+ brief="Notifications for new days"
+ )
+ @override_in_channel(AOC_WHITELIST)
+ async def aoc_subscribe(self, ctx: commands.Context) -> None:
+ """Assign the role for notifications about new days being ready."""
+ role = ctx.guild.get_role(AocConfig.role_id)
+ unsubscribe_command = f"{ctx.prefix}{ctx.command.root_parent} unsubscribe"
+
+ if role not in ctx.author.roles:
+ await ctx.author.add_roles(role)
+ await ctx.send("Okay! You have been __subscribed__ to notifications about new Advent of Code tasks. "
+ f"You can run `{unsubscribe_command}` to disable them again for you.")
+ else:
+ await ctx.send("Hey, you already are receiving notifications about new Advent of Code tasks. "
+ f"If you don't want them any more, run `{unsubscribe_command}` instead.")
+
+ @adventofcode_group.command(name="unsubscribe", aliases=("unsub",), brief="Notifications for new days")
+ @override_in_channel(AOC_WHITELIST)
+ async def aoc_unsubscribe(self, ctx: commands.Context) -> None:
+ """Remove the role for notifications about new days being ready."""
+ role = ctx.guild.get_role(AocConfig.role_id)
+
+ if role in ctx.author.roles:
+ await ctx.author.remove_roles(role)
+ await ctx.send("Okay! You have been __unsubscribed__ from notifications about new Advent of Code tasks.")
+ else:
+ await ctx.send("Hey, you don't even get any notifications about new Advent of Code tasks currently anyway.")
+
+ @adventofcode_group.command(name="countdown", aliases=("count", "c"), brief="Return time left until next day")
+ @override_in_channel(AOC_WHITELIST)
+ async def aoc_countdown(self, ctx: commands.Context) -> None:
+ """Return time left until next day."""
+ if not is_in_advent():
+ datetime_now = datetime.now(EST)
+
+ # Calculate the delta to this & next year's December 1st to see which one is closest and not in the past
+ this_year = datetime(datetime_now.year, 12, 1, tzinfo=EST)
+ next_year = datetime(datetime_now.year + 1, 12, 1, tzinfo=EST)
+ deltas = (dec_first - datetime_now for dec_first in (this_year, next_year))
+ delta = min(delta for delta in deltas if delta >= timedelta()) # timedelta() gives 0 duration delta
+
+ # Add a finer timedelta if there's less than a day left
+ if delta.days == 0:
+ delta_str = f"approximately {delta.seconds // 3600} hours"
+ else:
+ delta_str = f"{delta.days} days"
+
+ await ctx.send(f"The Advent of Code event is not currently running. "
+ f"The next event will start in {delta_str}.")
+ return
+
+ tomorrow, time_left = time_left_to_aoc_midnight()
+
+ hours, minutes = time_left.seconds // 3600, time_left.seconds // 60 % 60
+
+ await ctx.send(f"There are {hours} hours and {minutes} minutes left until day {tomorrow.day}.")
+
+ @adventofcode_group.command(name="about", aliases=("ab", "info"), brief="Learn about Advent of Code")
+ @override_in_channel(AOC_WHITELIST)
+ async def about_aoc(self, ctx: commands.Context) -> None:
+ """Respond with an explanation of all things Advent of Code."""
+ await ctx.send("", embed=self.cached_about_aoc)
+
+ @adventofcode_group.command(name="join", aliases=("j",), brief="Learn how to join the leaderboard (via DM)")
+ @override_in_channel(AOC_WHITELIST)
+ async def join_leaderboard(self, ctx: commands.Context) -> None:
+ """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!"
+ )
+ 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")
+ else:
+ await ctx.message.add_reaction(Emojis.envelope)
+
+ @adventofcode_group.command(
+ name="leaderboard",
+ aliases=("board", "lb"),
+ brief="Get a snapshot of the PyDis private AoC leaderboard",
+ )
+ @override_in_channel(AOC_WHITELIST)
+ async def aoc_leaderboard(self, ctx: commands.Context, number_of_people_to_display: int = 10) -> None:
+ """
+ Pull the top number_of_people_to_display members from the PyDis leaderboard and post an embed.
+
+ For readability, number_of_people_to_display defaults to 10. A maximum value is configured in the
+ Advent of Code section of the bot constants. number_of_people_to_display values greater than this
+ limit will default to this maximum and provide feedback to the user.
+ """
+ async with ctx.typing():
+ await self._check_leaderboard_cache(ctx)
+
+ if not self.cached_private_leaderboard:
+ # Feedback on issues with leaderboard caching are sent by _check_leaderboard_cache()
+ # Short circuit here if there's an issue
+ return
+
+ number_of_people_to_display = await self._check_n_entries(ctx, number_of_people_to_display)
+
+ # Generate leaderboard table for embed
+ members_to_print = self.cached_private_leaderboard.top_n(number_of_people_to_display)
+ table = AocPrivateLeaderboard.build_leaderboard_embed(members_to_print)
+
+ # Build embed
+ aoc_embed = discord.Embed(
+ description=f"Total members: {len(self.cached_private_leaderboard.members)}",
+ colour=Colours.soft_green,
+ timestamp=self.cached_private_leaderboard.last_updated
+ )
+ aoc_embed.set_author(name="Advent of Code", url=self.private_leaderboard_url)
+ aoc_embed.set_footer(text="Last Updated")
+
+ await ctx.send(
+ content=f"Here's the current Top {number_of_people_to_display}! {Emojis.christmas_tree*3}\n\n{table}",
+ embed=aoc_embed,
+ )
+
+ @adventofcode_group.command(
+ name="stats",
+ aliases=("dailystats", "ds"),
+ brief="Get daily statistics for the PyDis private leaderboard"
+ )
+ @override_in_channel(AOC_WHITELIST)
+ async def private_leaderboard_daily_stats(self, ctx: commands.Context) -> None:
+ """
+ Respond with a table of the daily completion statistics for the PyDis private leaderboard.
+
+ Embed will display the total members and the number of users who have completed each day's puzzle
+ """
+ async with ctx.typing():
+ await self._check_leaderboard_cache(ctx)
+
+ if not self.cached_private_leaderboard:
+ # Feedback on issues with leaderboard caching are sent by _check_leaderboard_cache()
+ # Short circuit here if there's an issue
+ return
+
+ # Build ASCII table
+ total_members = len(self.cached_private_leaderboard.members)
+ _star = Emojis.star
+ header = f"{'Day':4}{_star:^8}{_star*2:^4}{'% ' + _star:^8}{'% ' + _star*2:^4}\n{'='*35}"
+ table = ""
+ for day, completions in enumerate(self.cached_private_leaderboard.daily_completion_summary):
+ per_one_star = f"{(completions[0]/total_members)*100:.2f}"
+ per_two_star = f"{(completions[1]/total_members)*100:.2f}"
+
+ table += f"{day+1:3}){completions[0]:^8}{completions[1]:^6}{per_one_star:^10}{per_two_star:^6}\n"
+
+ table = f"```\n{header}\n{table}```"
+
+ # Build embed
+ daily_stats_embed = discord.Embed(
+ colour=Colours.soft_green, timestamp=self.cached_private_leaderboard.last_updated
+ )
+ daily_stats_embed.set_author(name="Advent of Code", url=self._base_url)
+ daily_stats_embed.set_footer(text="Last Updated")
+
+ await ctx.send(
+ content=f"Here's the current daily statistics!\n\n{table}", embed=daily_stats_embed
+ )
+
+ @adventofcode_group.command(
+ name="global",
+ aliases=("globalboard", "gb"),
+ brief="Get a snapshot of the global AoC leaderboard",
+ )
+ @override_in_channel(AOC_WHITELIST)
+ async def global_leaderboard(self, ctx: commands.Context, number_of_people_to_display: int = 10) -> None:
+ """
+ Pull the top number_of_people_to_display members from the global AoC leaderboard and post an embed.
+
+ For readability, number_of_people_to_display defaults to 10. A maximum value is configured in the
+ Advent of Code section of the bot constants. number_of_people_to_display values greater than this
+ limit will default to this maximum and provide feedback to the user.
+ """
+ async with ctx.typing():
+ await self._check_leaderboard_cache(ctx, global_board=True)
+
+ if not self.cached_global_leaderboard:
+ # Feedback on issues with leaderboard caching are sent by _check_leaderboard_cache()
+ # Short circuit here if there's an issue
+ return
+
+ number_of_people_to_display = await self._check_n_entries(ctx, number_of_people_to_display)
+
+ # Generate leaderboard table for embed
+ members_to_print = self.cached_global_leaderboard.top_n(number_of_people_to_display)
+ table = AocGlobalLeaderboard.build_leaderboard_embed(members_to_print)
+
+ # Build embed
+ aoc_embed = discord.Embed(colour=Colours.soft_green, timestamp=self.cached_global_leaderboard.last_updated)
+ aoc_embed.set_author(name="Advent of Code", url=self._base_url)
+ aoc_embed.set_footer(text="Last Updated")
+
+ await ctx.send(
+ f"Here's the current global Top {number_of_people_to_display}! {Emojis.christmas_tree*3}\n\n{table}",
+ embed=aoc_embed,
+ )
+
+ async def _check_leaderboard_cache(self, ctx: commands.Context, global_board: bool = False) -> None:
+ """
+ Check age of current leaderboard & pull a new one if the board is too old.
+
+ global_board is a boolean to toggle between the global board and the Pydis private board
+ """
+ # Toggle between global & private leaderboards
+ if global_board:
+ log.debug("Checking global leaderboard cache")
+ leaderboard_str = "cached_global_leaderboard"
+ _shortstr = "global"
+ else:
+ log.debug("Checking private leaderboard cache")
+ leaderboard_str = "cached_private_leaderboard"
+ _shortstr = "private"
+
+ leaderboard = getattr(self, leaderboard_str)
+ if not leaderboard:
+ log.debug(f"No cached {_shortstr} leaderboard found")
+ await self._boardgetter(global_board)
+ else:
+ leaderboard_age = datetime.utcnow() - leaderboard.last_updated
+ age_seconds = leaderboard_age.total_seconds()
+ if age_seconds < AocConfig.leaderboard_cache_age_threshold_seconds:
+ log.debug(f"Cached {_shortstr} leaderboard age less than threshold ({age_seconds} seconds old)")
+ else:
+ log.debug(f"Cached {_shortstr} leaderboard age greater than threshold ({age_seconds} seconds old)")
+ await self._boardgetter(global_board)
+
+ leaderboard = getattr(self, leaderboard_str)
+ if not leaderboard:
+ await ctx.send(
+ "",
+ embed=_error_embed_helper(
+ title=f"Something's gone wrong and there's no cached {_shortstr} leaderboard!",
+ description="Please check in with a staff member.",
+ ),
+ )
+
+ async def _check_n_entries(self, ctx: commands.Context, number_of_people_to_display: int) -> int:
+ """Check for n > max_entries and n <= 0."""
+ max_entries = AocConfig.leaderboard_max_displayed_members
+ author = ctx.message.author
+ if not 0 <= number_of_people_to_display <= max_entries:
+ log.debug(
+ f"{author.name} ({author.id}) attempted to fetch an invalid number "
+ f" of entries from the AoC leaderboard ({number_of_people_to_display})"
+ )
+ await ctx.send(
+ f":x: {author.mention}, number of entries to display must be a positive "
+ f"integer less than or equal to {max_entries}\n\n"
+ f"Head to {self.private_leaderboard_url} to view the entire leaderboard"
+ )
+ number_of_people_to_display = max_entries
+
+ return number_of_people_to_display
+
+ def _build_about_embed(self) -> discord.Embed:
+ """Build and return the informational "About AoC" embed from the resources file."""
+ with self.about_aoc_filepath.open("r") as f:
+ embed_fields = json.load(f)
+
+ about_embed = discord.Embed(title=self._base_url, colour=Colours.soft_green, url=self._base_url)
+ about_embed.set_author(name="Advent of Code", url=self._base_url)
+ for field in embed_fields:
+ about_embed.add_field(**field)
+
+ about_embed.set_footer(text=f"Last Updated (UTC): {datetime.utcnow()}")
+
+ return about_embed
+
+ async def _boardgetter(self, global_board: bool) -> None:
+ """Invoke the proper leaderboard getter based on the global_board boolean."""
+ if global_board:
+ self.cached_global_leaderboard = await AocGlobalLeaderboard.from_url()
+ else:
+ self.cached_private_leaderboard = await AocPrivateLeaderboard.from_url()
+
+ def cog_unload(self) -> None:
+ """Cancel season-related tasks on cog unload."""
+ log.debug("Unloading the cog and canceling the background task.")
+ self.countdown_task.cancel()
+ self.status_task.cancel()
+
+
+class AocMember:
+ """Object representing the Advent of Code user."""
+
+ def __init__(self, name: str, aoc_id: int, stars: int, starboard: list, local_score: int, global_score: int):
+ self.name = name
+ self.aoc_id = aoc_id
+ self.stars = stars
+ self.starboard = starboard
+ self.local_score = local_score
+ self.global_score = global_score
+ self.completions = self._completions_from_starboard(self.starboard)
+
+ def __repr__(self):
+ """Generate a user-friendly representation of the AocMember & their score."""
+ return f"<{self.name} ({self.aoc_id}): {self.local_score}>"
+
+ @classmethod
+ def member_from_json(cls, injson: dict) -> "AocMember":
+ """
+ Generate an AocMember from AoC's private leaderboard API JSON.
+
+ injson is expected to be the dict contained in:
+
+ AoC_APIjson['members'][<member id>:str]
+
+ Returns an AocMember object
+ """
+ return cls(
+ name=injson["name"] if injson["name"] else "Anonymous User",
+ aoc_id=int(injson["id"]),
+ stars=injson["stars"],
+ starboard=cls._starboard_from_json(injson["completion_day_level"]),
+ local_score=injson["local_score"],
+ global_score=injson["global_score"],
+ )
+
+ @staticmethod
+ def _starboard_from_json(injson: dict) -> list:
+ """
+ Generate starboard from AoC's private leaderboard API JSON.
+
+ injson is expected to be the dict contained in:
+
+ AoC_APIjson['members'][<member id>:str]['completion_day_level']
+
+ Returns a list of 25 lists, where each nested list contains a pair of booleans representing
+ the code challenge completion status for that day
+ """
+ # Basic input validation
+ if not isinstance(injson, dict):
+ raise ValueError
+
+ # Initialize starboard
+ starboard = []
+ for _i in range(25):
+ starboard.append([False, False])
+
+ # Iterate over days, which are the keys of injson (as str)
+ for day in injson:
+ idx = int(day) - 1
+ # If there is a second star, the first star must be completed
+ if "2" in injson[day].keys():
+ starboard[idx] = [True, True]
+ # If the day exists in injson, then at least the first star is completed
+ else:
+ starboard[idx] = [True, False]
+
+ return starboard
+
+ @staticmethod
+ def _completions_from_starboard(starboard: list) -> tuple:
+ """Return days completed, as a (1 star, 2 star) tuple, from starboard."""
+ completions = [0, 0]
+ for day in starboard:
+ if day[0]:
+ completions[0] += 1
+ if day[1]:
+ completions[1] += 1
+
+ return tuple(completions)
+
+
+class AocPrivateLeaderboard:
+ """Object representing the Advent of Code private leaderboard."""
+
+ def __init__(self, members: list, owner_id: int, event_year: int):
+ self.members = members
+ self._owner_id = owner_id
+ self._event_year = event_year
+ self.last_updated = datetime.utcnow()
+
+ self.daily_completion_summary = self.calculate_daily_completion()
+
+ def top_n(self, n: int = 10) -> dict:
+ """
+ Return the top n participants on the leaderboard.
+
+ If n is not specified, default to the top 10
+ """
+ return self.members[:n]
+
+ def calculate_daily_completion(self) -> List[tuple]:
+ """
+ Calculate member completion rates by day.
+
+ Return a list of tuples for each day containing the number of users who completed each part
+ of the challenge
+ """
+ daily_member_completions = []
+ for day in range(25):
+ one_star_count = 0
+ two_star_count = 0
+ for member in self.members:
+ if member.starboard[day][1]:
+ one_star_count += 1
+ two_star_count += 1
+ elif member.starboard[day][0]:
+ one_star_count += 1
+ else:
+ daily_member_completions.append((one_star_count, two_star_count))
+
+ return(daily_member_completions)
+
+ @staticmethod
+ async def json_from_url(
+ leaderboard_id: int = AocConfig.leaderboard_id, year: int = AocConfig.year
+ ) -> "AocPrivateLeaderboard":
+ """
+ Request the API JSON from Advent of Code for leaderboard_id for the specified year's event.
+
+ If no year is input, year defaults to the current year
+ """
+ api_url = f"https://adventofcode.com/{year}/leaderboard/private/view/{leaderboard_id}.json"
+
+ log.debug("Querying Advent of Code Private Leaderboard API")
+ async with aiohttp.ClientSession(cookies=AOC_SESSION_COOKIE, headers=AOC_REQUEST_HEADER) as session:
+ async with session.get(api_url) as resp:
+ if resp.status == 200:
+ raw_dict = await resp.json()
+ else:
+ log.warning(f"Bad response received from AoC ({resp.status}), check session cookie")
+ resp.raise_for_status()
+
+ return raw_dict
+
+ @classmethod
+ def from_json(cls, injson: dict) -> "AocPrivateLeaderboard":
+ """Generate an AocPrivateLeaderboard object from AoC's private leaderboard API JSON."""
+ return cls(
+ members=cls._sorted_members(injson["members"]), owner_id=injson["owner_id"], event_year=injson["event"]
+ )
+
+ @classmethod
+ async def from_url(cls) -> "AocPrivateLeaderboard":
+ """Helper wrapping of AocPrivateLeaderboard.json_from_url and AocPrivateLeaderboard.from_json."""
+ api_json = await cls.json_from_url()
+ return cls.from_json(api_json)
+
+ @staticmethod
+ def _sorted_members(injson: dict) -> list:
+ """
+ Generate a sorted list of AocMember objects from AoC's private leaderboard API JSON.
+
+ Output list is sorted based on the AocMember.local_score
+ """
+ members = [AocMember.member_from_json(injson[member]) for member in injson]
+ members.sort(key=lambda x: x.local_score, reverse=True)
+
+ return members
+
+ @staticmethod
+ def build_leaderboard_embed(members_to_print: List[AocMember]) -> str:
+ """
+ Build a text table from members_to_print, a list of AocMember objects.
+
+ Returns a string to be used as the content of the bot's leaderboard response
+ """
+ stargroup = f"{Emojis.star}, {Emojis.star*2}"
+ header = f"{' '*3}{'Score'} {'Name':^25} {stargroup:^7}\n{'-'*44}"
+ table = ""
+ for i, member in enumerate(members_to_print):
+ if member.name == "Anonymous User":
+ name = f"{member.name} #{member.aoc_id}"
+ else:
+ name = member.name
+
+ table += (
+ f"{i+1:2}) {member.local_score:4} {name:25.25} "
+ f"({member.completions[0]:2}, {member.completions[1]:2})\n"
+ )
+ else:
+ table = f"```{header}\n{table}```"
+
+ return table
+
+
+class AocGlobalLeaderboard:
+ """Object representing the Advent of Code global leaderboard."""
+
+ def __init__(self, members: List[tuple]):
+ self.members = members
+ self.last_updated = datetime.utcnow()
+
+ def top_n(self, n: int = 10) -> dict:
+ """
+ Return the top n participants on the leaderboard.
+
+ If n is not specified, default to the top 10
+ """
+ return self.members[:n]
+
+ @classmethod
+ async def from_url(cls) -> "AocGlobalLeaderboard":
+ """
+ Generate an list of tuples for the entries on AoC's global leaderboard.
+
+ Because there is no API for this, web scraping needs to be used
+ """
+ aoc_url = f"https://adventofcode.com/{AocConfig.year}/leaderboard"
+
+ async with aiohttp.ClientSession(headers=AOC_REQUEST_HEADER) as session:
+ async with session.get(aoc_url) as resp:
+ if resp.status == 200:
+ raw_html = await resp.text()
+ else:
+ log.warning(f"Bad response received from AoC ({resp.status}), check session cookie")
+ resp.raise_for_status()
+
+ soup = BeautifulSoup(raw_html, "html.parser")
+ ele = soup.find_all("div", class_="leaderboard-entry")
+
+ exp = r"(?:[ ]{,2}(\d+)\))?[ ]+(\d+)\s+([\w\(\)\#\@\-\d ]+)"
+
+ lb_list = []
+ for entry in ele:
+ # Strip off the AoC++ decorator
+ raw_str = entry.text.replace("(AoC++)", "").rstrip()
+
+ # Use a regex to extract the info from the string to unify formatting
+ # Group 1: Rank
+ # Group 2: Global Score
+ # Group 3: Member string
+ r = re.match(exp, raw_str)
+
+ rank = int(r.group(1)) if r.group(1) else None
+ global_score = int(r.group(2))
+
+ member = r.group(3)
+ if member.lower().startswith("(anonymous"):
+ # Normalize anonymous user string by stripping () and title casing
+ member = re.sub(r"[\(\)]", "", member).title()
+
+ lb_list.append((rank, global_score, member))
+
+ return cls(lb_list)
+
+ @staticmethod
+ def build_leaderboard_embed(members_to_print: List[tuple]) -> str:
+ """
+ Build a text table from members_to_print, a list of tuples.
+
+ Returns a string to be used as the content of the bot's leaderboard response
+ """
+ header = f"{' '*4}{'Score'} {'Name':^25}\n{'-'*36}"
+ table = ""
+ for member in members_to_print:
+ # In the event of a tie, rank is None
+ if member[0]:
+ rank = f"{member[0]:3})"
+ else:
+ rank = f"{' ':4}"
+ table += f"{rank} {member[1]:4} {member[2]:25.25}\n"
+ else:
+ table = f"```{header}\n{table}```"
+
+ return table
+
+
+def _error_embed_helper(title: str, description: str) -> discord.Embed:
+ """Return a red-colored Embed with the given title and description."""
+ return discord.Embed(title=title, description=description, colour=discord.Colour.red())
+
+
+def setup(bot: commands.Bot) -> None:
+ """Advent of Code Cog load."""
+ bot.add_cog(AdventOfCode(bot))
diff --git a/bot/exts/christmas/hanukkah_embed.py b/bot/exts/christmas/hanukkah_embed.py
new file mode 100644
index 00000000..4f470a34
--- /dev/null
+++ b/bot/exts/christmas/hanukkah_embed.py
@@ -0,0 +1,113 @@
+import datetime
+import logging
+from typing import List
+
+from discord import Embed
+from discord.ext import commands
+
+from bot.constants import Colours, Month
+from bot.utils.decorators import in_month
+
+log = logging.getLogger(__name__)
+
+
+class HanukkahEmbed(commands.Cog):
+ """A cog that returns information about Hanukkah festival."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+ self.url = ("https://www.hebcal.com/hebcal/?v=1&cfg=json&maj=on&min=on&mod=on&nx=on&"
+ "year=now&month=x&ss=on&mf=on&c=on&geo=geoname&geonameid=3448439&m=50&s=on")
+ self.hanukkah_days = []
+ self.hanukkah_months = []
+ self.hanukkah_years = []
+
+ async def get_hanukkah_dates(self) -> List[str]:
+ """Gets the dates for hanukkah festival."""
+ hanukkah_dates = []
+ async with self.bot.http_session.get(self.url) as response:
+ json_data = await response.json()
+ festivals = json_data['items']
+ for festival in festivals:
+ if festival['title'].startswith('Chanukah'):
+ date = festival['date']
+ hanukkah_dates.append(date)
+ return hanukkah_dates
+
+ @in_month(Month.DECEMBER)
+ @commands.command(name='hanukkah', aliases=['chanukah'])
+ async def hanukkah_festival(self, ctx: commands.Context) -> None:
+ """Tells you about the Hanukkah Festivaltime of festival, festival day, etc)."""
+ hanukkah_dates = await self.get_hanukkah_dates()
+ self.hanukkah_dates_split(hanukkah_dates)
+ hanukkah_start_day = int(self.hanukkah_days[0])
+ hanukkah_start_month = int(self.hanukkah_months[0])
+ hanukkah_start_year = int(self.hanukkah_years[0])
+ hanukkah_end_day = int(self.hanukkah_days[8])
+ hanukkah_end_month = int(self.hanukkah_months[8])
+ hanukkah_end_year = int(self.hanukkah_years[8])
+
+ hanukkah_start = datetime.date(hanukkah_start_year, hanukkah_start_month, hanukkah_start_day)
+ hanukkah_end = datetime.date(hanukkah_end_year, hanukkah_end_month, hanukkah_end_day)
+ today = datetime.date.today()
+ # today = datetime.date(2019, 12, 24) (for testing)
+ day = str(today.day)
+ month = str(today.month)
+ year = str(today.year)
+ embed = Embed()
+ embed.title = 'Hanukkah'
+ embed.colour = Colours.blue
+ if day in self.hanukkah_days and month in self.hanukkah_months and year in self.hanukkah_years:
+ if int(day) == hanukkah_start_day:
+ now = datetime.datetime.utcnow()
+ now = str(now)
+ hours = int(now[11:13]) + 4 # using only hours
+ hanukkah_start_hour = 18
+ if hours < hanukkah_start_hour:
+ embed.description = (f"Hanukkah hasnt started yet, "
+ f"it will start in about {hanukkah_start_hour-hours} hour/s.")
+ return await ctx.send(embed=embed)
+ elif hours > hanukkah_start_hour:
+ embed.description = (f'It is the starting day of Hanukkah ! '
+ f'Its been {hours-hanukkah_start_hour} hours hanukkah started !')
+ return await ctx.send(embed=embed)
+ festival_day = self.hanukkah_days.index(day)
+ number_suffixes = ['st', 'nd', 'rd', 'th']
+ suffix = ''
+ if int(festival_day) == 1:
+ suffix = number_suffixes[0]
+ if int(festival_day) == 2:
+ suffix = number_suffixes[1]
+ if int(festival_day) == 3:
+ suffix = number_suffixes[2]
+ if int(festival_day) > 3:
+ suffix = number_suffixes[3]
+ message = ''
+ for _ in range(1, festival_day + 1):
+ message += ':menorah:'
+ embed.description = f'It is the {festival_day}{suffix} day of Hanukkah ! \n {message}'
+ await ctx.send(embed=embed)
+ else:
+ if today < hanukkah_start:
+ festival_starting_month = hanukkah_start.strftime('%B')
+ embed.description = (f"Hanukkah has not started yet. "
+ f"Hanukkah will start at sundown on {hanukkah_start_day}th "
+ f"of {festival_starting_month}.")
+ else:
+ festival_end_month = hanukkah_end.strftime('%B')
+ embed.description = (f"Looks like you missed Hanukkah !"
+ f"Hanukkah ended on {hanukkah_end_day}th of {festival_end_month}.")
+
+ await ctx.send(embed=embed)
+
+ def hanukkah_dates_split(self, hanukkah_dates: List[str]) -> None:
+ """We are splitting the dates for hanukkah into days, months and years."""
+ for date in hanukkah_dates:
+ self.hanukkah_days.append(date[8:10])
+ self.hanukkah_months.append(date[5:7])
+ self.hanukkah_years.append(date[0:4])
+
+
+def setup(bot: commands.Bot) -> None:
+ """Cog load."""
+ bot.add_cog(HanukkahEmbed(bot))
diff --git a/bot/exts/easter/__init__.py b/bot/exts/easter/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bot/exts/easter/__init__.py
diff --git a/bot/exts/easter/april_fools_vids.py b/bot/exts/easter/april_fools_vids.py
new file mode 100644
index 00000000..06108f02
--- /dev/null
+++ b/bot/exts/easter/april_fools_vids.py
@@ -0,0 +1,38 @@
+import logging
+import random
+from json import load
+from pathlib import Path
+
+from discord.ext import commands
+
+log = logging.getLogger(__name__)
+
+
+class AprilFoolVideos(commands.Cog):
+ """A cog for April Fools' that gets a random April Fools' video from Youtube."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+ self.yt_vids = self.load_json()
+ self.youtubers = ['google'] # will add more in future
+
+ @staticmethod
+ def load_json() -> dict:
+ """A function to load JSON data."""
+ p = Path('bot/resources/easter/april_fools_vids.json')
+ with p.open() as json_file:
+ all_vids = load(json_file)
+ return all_vids
+
+ @commands.command(name='fool')
+ async def april_fools(self, ctx: commands.Context) -> None:
+ """Get a random April Fools' video from Youtube."""
+ random_youtuber = random.choice(self.youtubers)
+ category = self.yt_vids[random_youtuber]
+ random_vid = random.choice(category)
+ await ctx.send(f"Check out this April Fools' video by {random_youtuber}.\n\n{random_vid['link']}")
+
+
+def setup(bot: commands.Bot) -> None:
+ """April Fools' Cog load."""
+ bot.add_cog(AprilFoolVideos(bot))
diff --git a/bot/exts/easter/avatar_easterifier.py b/bot/exts/easter/avatar_easterifier.py
new file mode 100644
index 00000000..8e8a3500
--- /dev/null
+++ b/bot/exts/easter/avatar_easterifier.py
@@ -0,0 +1,128 @@
+import asyncio
+import logging
+from io import BytesIO
+from pathlib import Path
+from typing import Tuple, Union
+
+import discord
+from PIL import Image
+from PIL.ImageOps import posterize
+from discord.ext import commands
+
+log = logging.getLogger(__name__)
+
+COLOURS = [
+ (255, 247, 0), (255, 255, 224), (0, 255, 127), (189, 252, 201), (255, 192, 203),
+ (255, 160, 122), (181, 115, 220), (221, 160, 221), (200, 162, 200), (238, 130, 238),
+ (135, 206, 235), (0, 204, 204), (64, 224, 208)
+] # Pastel colours - Easter-like
+
+
+class AvatarEasterifier(commands.Cog):
+ """Put an Easter spin on your avatar or image!"""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @staticmethod
+ def closest(x: Tuple[int, int, int]) -> Tuple[int, int, int]:
+ """
+ Finds the closest easter colour to a given pixel.
+
+ Returns a merge between the original colour and the closest colour
+ """
+ r1, g1, b1 = x
+
+ def distance(point: Tuple[int, int, int]) -> Tuple[int, int, int]:
+ """Finds the difference between a pastel colour and the original pixel colour."""
+ r2, g2, b2 = point
+ return ((r1 - r2)**2 + (g1 - g2)**2 + (b1 - b2)**2)
+
+ closest_colours = sorted(COLOURS, key=lambda point: distance(point))
+ r2, g2, b2 = closest_colours[0]
+ r = (r1 + r2) // 2
+ g = (g1 + g2) // 2
+ b = (b1 + b2) // 2
+
+ return (r, g, b)
+
+ @commands.command(pass_context=True, aliases=["easterify"])
+ async def avatareasterify(self, ctx: commands.Context, *colours: Union[discord.Colour, str]) -> None:
+ """
+ This "Easterifies" the user's avatar.
+
+ Given colours will produce a personalised egg in the corner, similar to the egg_decorate command.
+ If colours are not given, a nice little chocolate bunny will sit in the corner.
+ Colours are split by spaces, unless you wrap the colour name in double quotes.
+ Discord colour names, HTML colour names, XKCD colour names and hex values are accepted.
+ """
+ async def send(*args, **kwargs) -> str:
+ """
+ This replaces the original ctx.send.
+
+ When invoking the egg decorating command, the egg itself doesn't print to to the channel.
+ Returns the message content so that if any errors occur, the error message can be output.
+ """
+ if args:
+ return args[0]
+
+ async with ctx.typing():
+
+ # Grabs image of avatar
+ image_bytes = await ctx.author.avatar_url_as(size=256).read()
+
+ old = Image.open(BytesIO(image_bytes))
+ old = old.convert("RGBA")
+
+ # Grabs alpha channel since posterize can't be used with an RGBA image.
+ alpha = old.getchannel("A").getdata()
+ old = old.convert("RGB")
+ old = posterize(old, 6)
+
+ data = old.getdata()
+ setted_data = set(data)
+ new_d = {}
+
+ for x in setted_data:
+ new_d[x] = self.closest(x)
+ await asyncio.sleep(0) # Ensures discord doesn't break in the background.
+ new_data = [(*new_d[x], alpha[i]) if x in new_d else x for i, x in enumerate(data)]
+
+ im = Image.new("RGBA", old.size)
+ im.putdata(new_data)
+
+ if colours:
+ send_message = ctx.send
+ ctx.send = send # Assigns ctx.send to a fake send
+ egg = await ctx.invoke(self.bot.get_command("eggdecorate"), *colours)
+ if isinstance(egg, str): # When an error message occurs in eggdecorate.
+ return await send_message(egg)
+
+ ratio = 64 / egg.height
+ egg = egg.resize((round(egg.width * ratio), round(egg.height * ratio)))
+ egg = egg.convert("RGBA")
+ im.alpha_composite(egg, (im.width - egg.width, (im.height - egg.height)//2)) # Right centre.
+ ctx.send = send_message # Reassigns ctx.send
+ else:
+ bunny = Image.open(Path("bot/resources/easter/chocolate_bunny.png"))
+ im.alpha_composite(bunny, (im.width - bunny.width, (im.height - bunny.height)//2)) # Right centre.
+
+ bufferedio = BytesIO()
+ im.save(bufferedio, format="PNG")
+
+ bufferedio.seek(0)
+
+ file = discord.File(bufferedio, filename="easterified_avatar.png") # Creates file to be used in embed
+ embed = discord.Embed(
+ name="Your Lovely Easterified Avatar",
+ description="Here is your lovely avatar, all bright and colourful\nwith Easter pastel colours. Enjoy :D"
+ )
+ embed.set_image(url="attachment://easterified_avatar.png")
+ embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url)
+
+ await ctx.send(file=file, embed=embed)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Avatar Easterifier Cog load."""
+ bot.add_cog(AvatarEasterifier(bot))
diff --git a/bot/exts/easter/bunny_name_generator.py b/bot/exts/easter/bunny_name_generator.py
new file mode 100644
index 00000000..3ecf9be9
--- /dev/null
+++ b/bot/exts/easter/bunny_name_generator.py
@@ -0,0 +1,92 @@
+import json
+import logging
+import random
+import re
+from pathlib import Path
+from typing import List, Union
+
+from discord.ext import commands
+
+log = logging.getLogger(__name__)
+
+with Path("bot/resources/easter/bunny_names.json").open("r", encoding="utf8") as f:
+ BUNNY_NAMES = json.load(f)
+
+
+class BunnyNameGenerator(commands.Cog):
+ """Generate a random bunny name, or bunnify your Discord username!"""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ def find_separators(self, displayname: str) -> Union[List[str], None]:
+ """Check if Discord name contains spaces so we can bunnify an individual word in the name."""
+ new_name = re.split(r'[_.\s]', displayname)
+ if displayname not in new_name:
+ return new_name
+
+ def find_vowels(self, displayname: str) -> str:
+ """
+ Finds vowels in the user's display name.
+
+ If the Discord name contains a vowel and the letter y, it will match one or more of these patterns.
+
+ Only the most recently matched pattern will apply the changes.
+ """
+ expressions = [
+ (r'a.+y', 'patchy'),
+ (r'e.+y', 'ears'),
+ (r'i.+y', 'ditsy'),
+ (r'o.+y', 'oofy'),
+ (r'u.+y', 'uffy'),
+ ]
+
+ for exp, vowel_sub in expressions:
+ new_name = re.sub(exp, vowel_sub, displayname)
+ if new_name != displayname:
+ return new_name
+
+ def append_name(self, displayname: str) -> str:
+ """Adds a suffix to the end of the Discord name."""
+ extensions = ['foot', 'ear', 'nose', 'tail']
+ suffix = random.choice(extensions)
+ appended_name = displayname + suffix
+
+ return appended_name
+
+ @commands.command()
+ async def bunnyname(self, ctx: commands.Context) -> None:
+ """Picks a random bunny name from a JSON file."""
+ await ctx.send(random.choice(BUNNY_NAMES["names"]))
+
+ @commands.command()
+ async def bunnifyme(self, ctx: commands.Context) -> None:
+ """Gets your Discord username and bunnifies it."""
+ username = ctx.message.author.display_name
+
+ # If name contains spaces or other separators, get the individual words to randomly bunnify
+ spaces_in_name = self.find_separators(username)
+
+ # If name contains vowels, see if it matches any of the patterns in this function
+ # If there are matches, the bunnified name is returned.
+ vowels_in_name = self.find_vowels(username)
+
+ # Default if the checks above return None
+ unmatched_name = self.append_name(username)
+
+ if spaces_in_name is not None:
+ replacements = ['Cotton', 'Fluff', 'Floof' 'Bounce', 'Snuffle', 'Nibble', 'Cuddle', 'Velvetpaw', 'Carrot']
+ word_to_replace = random.choice(spaces_in_name)
+ substitute = random.choice(replacements)
+ bunnified_name = username.replace(word_to_replace, substitute)
+ elif vowels_in_name is not None:
+ bunnified_name = vowels_in_name
+ elif unmatched_name:
+ bunnified_name = unmatched_name
+
+ await ctx.send(bunnified_name)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Bunny Name Generator Cog load."""
+ bot.add_cog(BunnyNameGenerator(bot))
diff --git a/bot/exts/easter/conversationstarters.py b/bot/exts/easter/conversationstarters.py
new file mode 100644
index 00000000..a5f40445
--- /dev/null
+++ b/bot/exts/easter/conversationstarters.py
@@ -0,0 +1,28 @@
+import json
+import logging
+import random
+from pathlib import Path
+
+from discord.ext import commands
+
+log = logging.getLogger(__name__)
+
+with open(Path("bot/resources/easter/starter.json"), "r", encoding="utf8") as f:
+ starters = json.load(f)
+
+
+class ConvoStarters(commands.Cog):
+ """Easter conversation topics."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @commands.command()
+ async def topic(self, ctx: commands.Context) -> None:
+ """Responds with a random topic to start a conversation."""
+ await ctx.send(random.choice(starters['starters']))
+
+
+def setup(bot: commands.Bot) -> None:
+ """Conversation starters Cog load."""
+ bot.add_cog(ConvoStarters(bot))
diff --git a/bot/exts/easter/easter_riddle.py b/bot/exts/easter/easter_riddle.py
new file mode 100644
index 00000000..8977534f
--- /dev/null
+++ b/bot/exts/easter/easter_riddle.py
@@ -0,0 +1,100 @@
+import asyncio
+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 Path("bot/resources/easter/easter_riddle.json").open("r", encoding="utf8") as f:
+ RIDDLE_QUESTIONS = load(f)
+
+TIMELIMIT = 10
+
+
+class EasterRiddle(commands.Cog):
+ """This cog contains the command for the Easter quiz!"""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+ self.winners = []
+ self.correct = ""
+ self.current_channel = None
+
+ @commands.command(aliases=["riddlemethis", "riddleme"])
+ async def riddle(self, ctx: commands.Context) -> None:
+ """
+ Gives a random riddle, then provides 2 hints at certain intervals before revealing the answer.
+
+ The duration of the hint interval can be configured by changing the TIMELIMIT constant in this file.
+ """
+ if self.current_channel:
+ return await ctx.send(f"A riddle is already being solved in {self.current_channel.mention}!")
+
+ self.current_channel = ctx.message.channel
+
+ random_question = random.choice(RIDDLE_QUESTIONS)
+ question = random_question["question"]
+ hints = random_question["riddles"]
+ self.correct = random_question["correct_answer"]
+
+ description = f"You have {TIMELIMIT} seconds before the first hint."
+
+ riddle_embed = discord.Embed(title=question, description=description, colour=Colours.pink)
+
+ await ctx.send(embed=riddle_embed)
+ await asyncio.sleep(TIMELIMIT)
+
+ hint_embed = discord.Embed(
+ title=f"Here's a hint: {hints[0]}!",
+ colour=Colours.pink
+ )
+
+ await ctx.send(embed=hint_embed)
+ await asyncio.sleep(TIMELIMIT)
+
+ hint_embed = discord.Embed(
+ title=f"Here's a hint: {hints[1]}!",
+ colour=Colours.pink
+ )
+
+ await ctx.send(embed=hint_embed)
+ await asyncio.sleep(TIMELIMIT)
+
+ if self.winners:
+ win_list = " ".join(self.winners)
+ content = f"Well done {win_list} for getting it right!"
+ else:
+ content = "Nobody got it right..."
+
+ answer_embed = discord.Embed(
+ title=f"The answer is: {self.correct}!",
+ colour=Colours.pink
+ )
+
+ await ctx.send(content, embed=answer_embed)
+
+ self.winners = []
+ self.current_channel = None
+
+ @commands.Cog.listener()
+ async def on_message(self, message: discord.Message) -> None:
+ """If a non-bot user enters a correct answer, their username gets added to self.winners."""
+ if self.current_channel != message.channel:
+ return
+
+ if self.bot.user == message.author:
+ return
+
+ if message.content.lower() == self.correct.lower():
+ self.winners.append(message.author.mention)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Easter Riddle Cog load."""
+ bot.add_cog(EasterRiddle(bot))
diff --git a/bot/exts/easter/egg_decorating.py b/bot/exts/easter/egg_decorating.py
new file mode 100644
index 00000000..be228b2c
--- /dev/null
+++ b/bot/exts/easter/egg_decorating.py
@@ -0,0 +1,118 @@
+import json
+import logging
+import random
+from contextlib import suppress
+from io import BytesIO
+from pathlib import Path
+from typing import Union
+
+import discord
+from PIL import Image
+from discord.ext import commands
+
+log = logging.getLogger(__name__)
+
+with open(Path("bot/resources/evergreen/html_colours.json")) as f:
+ HTML_COLOURS = json.load(f)
+
+with open(Path("bot/resources/evergreen/xkcd_colours.json")) as f:
+ XKCD_COLOURS = json.load(f)
+
+COLOURS = [
+ (255, 0, 0, 255), (255, 128, 0, 255), (255, 255, 0, 255), (0, 255, 0, 255),
+ (0, 255, 255, 255), (0, 0, 255, 255), (255, 0, 255, 255), (128, 0, 128, 255)
+] # Colours to be replaced - Red, Orange, Yellow, Green, Light Blue, Dark Blue, Pink, Purple
+
+IRREPLACEABLE = [
+ (0, 0, 0, 0), (0, 0, 0, 255)
+] # Colours that are meant to stay the same - Transparent and Black
+
+
+class EggDecorating(commands.Cog):
+ """Decorate some easter eggs!"""
+
+ def __init__(self, bot: commands.Bot) -> None:
+ self.bot = bot
+
+ @staticmethod
+ def replace_invalid(colour: str) -> Union[int, None]:
+ """Attempts to match with HTML or XKCD colour names, returning the int value."""
+ with suppress(KeyError):
+ return int(HTML_COLOURS[colour], 16)
+ with suppress(KeyError):
+ return int(XKCD_COLOURS[colour], 16)
+ return None
+
+ @commands.command(aliases=["decorateegg"])
+ async def eggdecorate(
+ self, ctx: commands.Context, *colours: Union[discord.Colour, str]
+ ) -> Union[Image.Image, discord.Message]:
+ """
+ Picks a random egg design and decorates it using the given colours.
+
+ Colours are split by spaces, unless you wrap the colour name in double quotes.
+ Discord colour names, HTML colour names, XKCD colour names and hex values are accepted.
+ """
+ if len(colours) < 2:
+ return await ctx.send("You must include at least 2 colours!")
+
+ invalid = []
+ colours = list(colours)
+ for idx, colour in enumerate(colours):
+ if isinstance(colour, discord.Colour):
+ continue
+ value = self.replace_invalid(colour)
+ if value:
+ colours[idx] = discord.Colour(value)
+ else:
+ invalid.append(colour)
+
+ if len(invalid) > 1:
+ return await ctx.send(f"Sorry, I don't know these colours: {' '.join(invalid)}")
+ elif len(invalid) == 1:
+ return await ctx.send(f"Sorry, I don't know the colour {invalid[0]}!")
+
+ async with ctx.typing():
+ # Expand list to 8 colours
+ colours_n = len(colours)
+ if colours_n < 8:
+ q, r = divmod(8, colours_n)
+ colours = colours * q + colours[:r]
+ num = random.randint(1, 6)
+ im = Image.open(Path(f"bot/resources/easter/easter_eggs/design{num}.png"))
+ data = list(im.getdata())
+
+ replaceable = {x for x in data if x not in IRREPLACEABLE}
+ replaceable = sorted(replaceable, key=COLOURS.index)
+
+ replacing_colours = {colour: colours[i] for i, colour in enumerate(replaceable)}
+ new_data = []
+ for x in data:
+ if x in replacing_colours:
+ new_data.append((*replacing_colours[x].to_rgb(), 255))
+ # Also ensures that the alpha channel has a value
+ else:
+ new_data.append(x)
+ new_im = Image.new(im.mode, im.size)
+ new_im.putdata(new_data)
+
+ bufferedio = BytesIO()
+ new_im.save(bufferedio, format="PNG")
+
+ bufferedio.seek(0)
+
+ file = discord.File(bufferedio, filename="egg.png") # Creates file to be used in embed
+ embed = discord.Embed(
+ title="Your Colourful Easter Egg",
+ description="Here is your pretty little egg. Hope you like it!"
+ )
+ embed.set_image(url="attachment://egg.png")
+ embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url)
+
+ await ctx.send(file=file, embed=embed)
+ return new_im
+
+
+def setup(bot: commands.bot) -> None:
+ """Egg decorating Cog load."""
+ bot.add_cog(EggDecorating(bot))
diff --git a/bot/exts/easter/egg_facts.py b/bot/exts/easter/egg_facts.py
new file mode 100644
index 00000000..83918fb0
--- /dev/null
+++ b/bot/exts/easter/egg_facts.py
@@ -0,0 +1,60 @@
+import logging
+import random
+from json import load
+from pathlib import Path
+
+import discord
+from discord.ext import commands
+
+from bot.constants import Channels, Colours, Month
+from bot.utils.decorators import seasonal_task
+
+log = logging.getLogger(__name__)
+
+
+class EasterFacts(commands.Cog):
+ """
+ A cog contains a command that will return an easter egg fact when called.
+
+ It also contains a background task which sends an easter egg fact in the event channel everyday.
+ """
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+ self.facts = self.load_json()
+
+ self.daily_fact_task = self.bot.loop.create_task(self.send_egg_fact_daily())
+
+ @staticmethod
+ def load_json() -> dict:
+ """Load a list of easter egg facts from the resource JSON file."""
+ p = Path("bot/resources/easter/easter_egg_facts.json")
+ with p.open(encoding="utf8") as f:
+ return load(f)
+
+ @seasonal_task(Month.APRIL)
+ async def send_egg_fact_daily(self) -> None:
+ """A background task that sends an easter egg fact in the event channel everyday."""
+ await self.bot.wait_until_ready()
+
+ channel = self.bot.get_channel(Channels.seasonalbot_commands)
+ await channel.send(embed=self.make_embed())
+
+ @commands.command(name='eggfact', aliases=['fact'])
+ async def easter_facts(self, ctx: commands.Context) -> None:
+ """Get easter egg facts."""
+ embed = self.make_embed()
+ await ctx.send(embed=embed)
+
+ def make_embed(self) -> discord.Embed:
+ """Makes a nice embed for the message to be sent."""
+ return discord.Embed(
+ colour=Colours.soft_red,
+ title="Easter Egg Fact",
+ description=random.choice(self.facts)
+ )
+
+
+def setup(bot: commands.Bot) -> None:
+ """Easter Egg facts cog load."""
+ bot.add_cog(EasterFacts(bot))
diff --git a/bot/exts/easter/egghead_quiz.py b/bot/exts/easter/egghead_quiz.py
new file mode 100644
index 00000000..0498d9db
--- /dev/null
+++ b/bot/exts/easter/egghead_quiz.py
@@ -0,0 +1,119 @@
+import asyncio
+import logging
+import random
+from json import load
+from pathlib import Path
+from typing import Union
+
+import discord
+from discord.ext import commands
+
+from bot.constants import Colours
+
+log = logging.getLogger(__name__)
+
+with open(Path("bot/resources/easter/egghead_questions.json"), "r", encoding="utf8") as f:
+ EGGHEAD_QUESTIONS = load(f)
+
+
+EMOJIS = [
+ '\U0001f1e6', '\U0001f1e7', '\U0001f1e8', '\U0001f1e9', '\U0001f1ea',
+ '\U0001f1eb', '\U0001f1ec', '\U0001f1ed', '\U0001f1ee', '\U0001f1ef',
+ '\U0001f1f0', '\U0001f1f1', '\U0001f1f2', '\U0001f1f3', '\U0001f1f4',
+ '\U0001f1f5', '\U0001f1f6', '\U0001f1f7', '\U0001f1f8', '\U0001f1f9',
+ '\U0001f1fa', '\U0001f1fb', '\U0001f1fc', '\U0001f1fd', '\U0001f1fe',
+ '\U0001f1ff'
+] # Regional Indicators A-Z (used for voting)
+
+TIMELIMIT = 30
+
+
+class EggheadQuiz(commands.Cog):
+ """This cog contains the command for the Easter quiz!"""
+
+ def __init__(self, bot: commands.Bot) -> None:
+ self.bot = bot
+ self.quiz_messages = {}
+
+ @commands.command(aliases=["eggheadquiz", "easterquiz"])
+ async def eggquiz(self, ctx: commands.Context) -> None:
+ """
+ Gives a random quiz question, waits 30 seconds and then outputs the answer.
+
+ Also informs of the percentages and votes of each option
+ """
+ random_question = random.choice(EGGHEAD_QUESTIONS)
+ question, answers = random_question["question"], random_question["answers"]
+ answers = [(EMOJIS[i], a) for i, a in enumerate(answers)]
+ correct = EMOJIS[random_question["correct_answer"]]
+
+ valid_emojis = [emoji for emoji, _ in answers]
+
+ description = f"You have {TIMELIMIT} seconds to vote.\n\n"
+ description += "\n".join([f"{emoji} -> **{answer}**" for emoji, answer in answers])
+
+ q_embed = discord.Embed(title=question, description=description, colour=Colours.pink)
+
+ msg = await ctx.send(embed=q_embed)
+ for emoji in valid_emojis:
+ await msg.add_reaction(emoji)
+
+ self.quiz_messages[msg.id] = valid_emojis
+
+ await asyncio.sleep(TIMELIMIT)
+
+ del self.quiz_messages[msg.id]
+
+ msg = await ctx.channel.fetch_message(msg.id) # Refreshes message
+
+ total_no = sum([len(await r.users().flatten()) for r in msg.reactions]) - len(valid_emojis) # - bot's reactions
+
+ if total_no == 0:
+ return await msg.delete() # To avoid ZeroDivisionError if nobody reacts
+
+ results = ["**VOTES:**"]
+ for emoji, _ in answers:
+ num = [len(await r.users().flatten()) for r in msg.reactions if str(r.emoji) == emoji][0] - 1
+ percent = round(100 * num / total_no)
+ s = "" if num == 1 else "s"
+ string = f"{emoji} - {num} vote{s} ({percent}%)"
+ results.append(string)
+
+ mentions = " ".join([
+ u.mention for u in [
+ await r.users().flatten() for r in msg.reactions if str(r.emoji) == correct
+ ][0] if not u.bot
+ ])
+
+ content = f"Well done {mentions} for getting it correct!" if mentions else "Nobody got it right..."
+
+ a_embed = discord.Embed(
+ title=f"The correct answer was {correct}!",
+ description="\n".join(results),
+ colour=Colours.pink
+ )
+
+ await ctx.send(content, embed=a_embed)
+
+ @staticmethod
+ async def already_reacted(message: discord.Message, user: Union[discord.Member, discord.User]) -> bool:
+ """Returns whether a given user has reacted more than once to a given message."""
+ users = [u.id for reaction in [await r.users().flatten() for r in message.reactions] for u in reaction]
+ return users.count(user.id) > 1 # Old reaction plus new reaction
+
+ @commands.Cog.listener()
+ async def on_reaction_add(self, reaction: discord.Reaction, user: Union[discord.Member, discord.User]) -> None:
+ """Listener to listen specifically for reactions of quiz messages."""
+ if user.bot:
+ return
+ if reaction.message.id not in self.quiz_messages:
+ return
+ if str(reaction.emoji) not in self.quiz_messages[reaction.message.id]:
+ return await reaction.message.remove_reaction(reaction, user)
+ if await self.already_reacted(reaction.message, user):
+ return await reaction.message.remove_reaction(reaction, user)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Egghead Quiz Cog load."""
+ bot.add_cog(EggheadQuiz(bot))
diff --git a/bot/exts/easter/traditions.py b/bot/exts/easter/traditions.py
new file mode 100644
index 00000000..85b4adfb
--- /dev/null
+++ b/bot/exts/easter/traditions.py
@@ -0,0 +1,30 @@
+import json
+import logging
+import random
+from pathlib import Path
+
+from discord.ext import commands
+
+log = logging.getLogger(__name__)
+
+with open(Path("bot/resources/easter/traditions.json"), "r", encoding="utf8") as f:
+ traditions = json.load(f)
+
+
+class Traditions(commands.Cog):
+ """A cog which allows users to get a random easter tradition or custom from a random country."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @commands.command(aliases=('eastercustoms',))
+ async def easter_tradition(self, ctx: commands.Context) -> None:
+ """Responds with a random tradition or custom."""
+ random_country = random.choice(list(traditions))
+
+ await ctx.send(f"{random_country}:\n{traditions[random_country]}")
+
+
+def setup(bot: commands.Bot) -> None:
+ """Traditions Cog load."""
+ bot.add_cog(Traditions(bot))
diff --git a/bot/exts/evergreen/8bitify.py b/bot/exts/evergreen/8bitify.py
new file mode 100644
index 00000000..60062fc1
--- /dev/null
+++ b/bot/exts/evergreen/8bitify.py
@@ -0,0 +1,54 @@
+from io import BytesIO
+
+import discord
+from PIL import Image
+from discord.ext import commands
+
+
+class EightBitify(commands.Cog):
+ """Make your avatar 8bit!"""
+
+ def __init__(self, bot: commands.Bot) -> None:
+ self.bot = bot
+
+ @staticmethod
+ def pixelate(image: Image) -> Image:
+ """Takes an image and pixelates it."""
+ return image.resize((32, 32)).resize((1024, 1024))
+
+ @staticmethod
+ def quantize(image: Image) -> Image:
+ """Reduces colour palette to 256 colours."""
+ return image.quantize(colors=32)
+
+ @commands.command(name="8bitify")
+ async def eightbit_command(self, ctx: commands.Context) -> None:
+ """Pixelates your avatar and changes the palette to an 8bit one."""
+ async with ctx.typing():
+ image_bytes = await ctx.author.avatar_url.read()
+ avatar = Image.open(BytesIO(image_bytes))
+ avatar = avatar.convert("RGBA").resize((1024, 1024))
+
+ eightbit = self.pixelate(avatar)
+ eightbit = self.quantize(eightbit)
+
+ bufferedio = BytesIO()
+ eightbit.save(bufferedio, format="PNG")
+ bufferedio.seek(0)
+
+ file = discord.File(bufferedio, filename="8bitavatar.png")
+
+ embed = discord.Embed(
+ title="Your 8-bit avatar",
+ description='Here is your avatar. I think it looks all cool and "retro"'
+ )
+
+ embed.set_image(url="attachment://8bitavatar.png")
+ embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url)
+
+ await ctx.send(file=file, embed=embed)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Cog load."""
+ bot.add_cog(EightBitify(bot))
diff --git a/bot/exts/evergreen/__init__.py b/bot/exts/evergreen/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bot/exts/evergreen/__init__.py
diff --git a/bot/exts/evergreen/battleship.py b/bot/exts/evergreen/battleship.py
new file mode 100644
index 00000000..9bc374e6
--- /dev/null
+++ b/bot/exts/evergreen/battleship.py
@@ -0,0 +1,443 @@
+import asyncio
+import logging
+import random
+import re
+import typing
+from dataclasses import dataclass
+from functools import partial
+
+import discord
+from discord.ext import commands
+
+from bot.constants import Colours
+
+log = logging.getLogger(__name__)
+
+
+@dataclass
+class Square:
+ """Each square on the battleship grid - if they contain a boat and if they've been aimed at."""
+
+ boat: typing.Optional[str]
+ aimed: bool
+
+
+Grid = typing.List[typing.List[Square]]
+EmojiSet = typing.Dict[typing.Tuple[bool, bool], str]
+
+
+@dataclass
+class Player:
+ """Each player in the game - their messages for the boards and their current grid."""
+
+ user: discord.Member
+ board: discord.Message
+ opponent_board: discord.Message
+ grid: Grid
+
+
+# The name of the ship and its size
+SHIPS = {
+ "Carrier": 5,
+ "Battleship": 4,
+ "Cruiser": 3,
+ "Submarine": 3,
+ "Destroyer": 2,
+}
+
+
+# For these two variables, the first boolean is whether the square is a ship (True) or not (False).
+# The second boolean is whether the player has aimed for that square (True) or not (False)
+
+# This is for the player's own board which shows the location of their own ships.
+SHIP_EMOJIS = {
+ (True, True): ":fire:",
+ (True, False): ":ship:",
+ (False, True): ":anger:",
+ (False, False): ":ocean:",
+}
+
+# This is for the opposing player's board which only shows aimed locations.
+HIDDEN_EMOJIS = {
+ (True, True): ":red_circle:",
+ (True, False): ":black_circle:",
+ (False, True): ":white_circle:",
+ (False, False): ":black_circle:",
+}
+
+# For the top row of the board
+LETTERS = (
+ ":stop_button::regional_indicator_a::regional_indicator_b::regional_indicator_c::regional_indicator_d:"
+ ":regional_indicator_e::regional_indicator_f::regional_indicator_g::regional_indicator_h:"
+ ":regional_indicator_i::regional_indicator_j:"
+)
+
+# For the first column of the board
+NUMBERS = [
+ ":one:",
+ ":two:",
+ ":three:",
+ ":four:",
+ ":five:",
+ ":six:",
+ ":seven:",
+ ":eight:",
+ ":nine:",
+ ":keycap_ten:",
+]
+
+CROSS_EMOJI = "\u274e"
+HAND_RAISED_EMOJI = "\U0001f64b"
+
+
+class Game:
+ """A Battleship Game."""
+
+ def __init__(
+ self,
+ bot: commands.Bot,
+ channel: discord.TextChannel,
+ player1: discord.Member,
+ player2: discord.Member
+ ) -> None:
+
+ self.bot = bot
+ self.public_channel = channel
+
+ self.p1 = Player(player1, None, None, self.generate_grid())
+ self.p2 = Player(player2, None, None, self.generate_grid())
+
+ self.gameover: bool = False
+
+ self.turn: typing.Optional[discord.Member] = None
+ self.next: typing.Optional[discord.Member] = None
+
+ self.match: typing.Optional[typing.Match] = None
+ self.surrender: bool = False
+
+ self.setup_grids()
+
+ @staticmethod
+ def generate_grid() -> Grid:
+ """Generates a grid by instantiating the Squares."""
+ return [[Square(None, False) for _ in range(10)] for _ in range(10)]
+
+ @staticmethod
+ def format_grid(player: Player, emojiset: EmojiSet) -> str:
+ """
+ Gets and formats the grid as a list into a string to be output to the DM.
+
+ Also adds the Letter and Number indexes.
+ """
+ grid = [
+ [emojiset[bool(square.boat), square.aimed] for square in row]
+ for row in player.grid
+ ]
+
+ rows = ["".join([number] + row) for number, row in zip(NUMBERS, grid)]
+ return "\n".join([LETTERS] + rows)
+
+ @staticmethod
+ def get_square(grid: Grid, square: str) -> Square:
+ """Grabs a square from a grid with an inputted key."""
+ index = ord(square[0]) - ord("A")
+ number = int(square[1:])
+
+ return grid[number-1][index] # -1 since lists are indexed from 0
+
+ async def game_over(
+ self,
+ *,
+ winner: discord.Member,
+ loser: discord.Member
+ ) -> None:
+ """Removes games from list of current games and announces to public chat."""
+ await self.public_channel.send(f"Game Over! {winner.mention} won against {loser.mention}")
+
+ for player in (self.p1, self.p2):
+ grid = self.format_grid(player, SHIP_EMOJIS)
+ await self.public_channel.send(f"{player.user}'s Board:\n{grid}")
+
+ @staticmethod
+ def check_sink(grid: Grid, boat: str) -> bool:
+ """Checks if all squares containing a given boat have sunk."""
+ return all(square.aimed for row in grid for square in row if square.boat == boat)
+
+ @staticmethod
+ def check_gameover(grid: Grid) -> bool:
+ """Checks if all boats have been sunk."""
+ return all(square.aimed for row in grid for square in row if square.boat)
+
+ def setup_grids(self) -> None:
+ """Places the boats on the grids to initialise the game."""
+ for player in (self.p1, self.p2):
+ for name, size in SHIPS.items():
+ while True: # Repeats if about to overwrite another boat
+ ship_collision = False
+ coords = []
+
+ coord1 = random.randint(0, 9)
+ coord2 = random.randint(0, 10 - size)
+
+ if random.choice((True, False)): # Vertical or Horizontal
+ x, y = coord1, coord2
+ xincr, yincr = 0, 1
+ else:
+ x, y = coord2, coord1
+ xincr, yincr = 1, 0
+
+ for i in range(size):
+ new_x = x + (xincr * i)
+ new_y = y + (yincr * i)
+ if player.grid[new_x][new_y].boat: # Check if there's already a boat
+ ship_collision = True
+ break
+ coords.append((new_x, new_y))
+ if not ship_collision: # If not overwriting any other boat spaces, break loop
+ break
+
+ for x, y in coords:
+ player.grid[x][y].boat = name
+
+ async def print_grids(self) -> None:
+ """Prints grids to the DM channels."""
+ # Convert squares into Emoji
+
+ boards = [
+ self.format_grid(player, emojiset)
+ for emojiset in (HIDDEN_EMOJIS, SHIP_EMOJIS)
+ for player in (self.p1, self.p2)
+ ]
+
+ locations = (
+ (self.p2, "opponent_board"), (self.p1, "opponent_board"),
+ (self.p1, "board"), (self.p2, "board")
+ )
+
+ for board, location in zip(boards, locations):
+ player, attr = location
+ if getattr(player, attr):
+ await getattr(player, attr).edit(content=board)
+ else:
+ setattr(player, attr, await player.user.send(board))
+
+ def predicate(self, message: discord.Message) -> bool:
+ """Predicate checking the message typed for each turn."""
+ if message.author == self.turn.user and message.channel == self.turn.user.dm_channel:
+ if message.content.lower() == "surrender":
+ self.surrender = True
+ return True
+ self.match = re.match("([A-J]|[a-j]) ?((10)|[1-9])", message.content.strip())
+ if not self.match:
+ self.bot.loop.create_task(message.add_reaction(CROSS_EMOJI))
+ return bool(self.match)
+
+ async def take_turn(self) -> typing.Optional[Square]:
+ """Lets the player who's turn it is choose a square."""
+ square = None
+ turn_message = await self.turn.user.send(
+ "It's your turn! Type the square you want to fire at. Format it like this: A1\n"
+ "Type `surrender` to give up"
+ )
+ await self.next.user.send("Their turn", delete_after=3.0)
+ while True:
+ try:
+ await self.bot.wait_for("message", check=self.predicate, timeout=60.0)
+ except asyncio.TimeoutError:
+ await self.turn.user.send("You took too long. Game over!")
+ await self.next.user.send(f"{self.turn.user} took too long. Game over!")
+ await self.public_channel.send(
+ f"Game over! {self.turn.user.mention} timed out so {self.next.user.mention} wins!"
+ )
+ self.gameover = True
+ break
+ else:
+ if self.surrender:
+ await self.next.user.send(f"{self.turn.user} surrendered. Game over!")
+ await self.public_channel.send(
+ f"Game over! {self.turn.user.mention} surrendered to {self.next.user.mention}!"
+ )
+ self.gameover = True
+ break
+ square = self.get_square(self.next.grid, self.match.string)
+ if square.aimed:
+ await self.turn.user.send("You've already aimed at this square!", delete_after=3.0)
+ else:
+ break
+ await turn_message.delete()
+ return square
+
+ async def hit(self, square: Square, alert_messages: typing.List[discord.Message]) -> None:
+ """Occurs when a player successfully aims for a ship."""
+ await self.turn.user.send("Hit!", delete_after=3.0)
+ alert_messages.append(await self.next.user.send("Hit!"))
+ if self.check_sink(self.next.grid, square.boat):
+ await self.turn.user.send(f"You've sunk their {square.boat} ship!", delete_after=3.0)
+ alert_messages.append(await self.next.user.send(f"Oh no! Your {square.boat} ship sunk!"))
+ if self.check_gameover(self.next.grid):
+ await self.turn.user.send("You win!")
+ await self.next.user.send("You lose!")
+ self.gameover = True
+ await self.game_over(winner=self.turn.user, loser=self.next.user)
+
+ async def start_game(self) -> None:
+ """Begins the game."""
+ await self.p1.user.send(f"You're playing battleship with {self.p2.user}.")
+ await self.p2.user.send(f"You're playing battleship with {self.p1.user}.")
+
+ alert_messages = []
+
+ self.turn = self.p1
+ self.next = self.p2
+
+ while True:
+ await self.print_grids()
+
+ if self.gameover:
+ return
+
+ square = await self.take_turn()
+ if not square:
+ return
+ square.aimed = True
+
+ for message in alert_messages:
+ await message.delete()
+
+ alert_messages = []
+ alert_messages.append(await self.next.user.send(f"{self.turn.user} aimed at {self.match.string}!"))
+
+ if square.boat:
+ await self.hit(square, alert_messages)
+ if self.gameover:
+ return
+ else:
+ await self.turn.user.send("Miss!", delete_after=3.0)
+ alert_messages.append(await self.next.user.send("Miss!"))
+
+ self.turn, self.next = self.next, self.turn
+
+
+class Battleship(commands.Cog):
+ """Play the classic game Battleship!"""
+
+ def __init__(self, bot: commands.Bot) -> None:
+ self.bot = bot
+ self.games: typing.List[Game] = []
+ self.waiting: typing.List[discord.Member] = []
+
+ def predicate(
+ self,
+ ctx: commands.Context,
+ announcement: discord.Message,
+ reaction: discord.Reaction,
+ user: discord.Member
+ ) -> bool:
+ """Predicate checking the criteria for the announcement message."""
+ if self.already_playing(ctx.author): # If they've joined a game since requesting a player 2
+ return True # Is dealt with later on
+ if (
+ user.id not in (ctx.me.id, ctx.author.id)
+ and str(reaction.emoji) == HAND_RAISED_EMOJI
+ and reaction.message.id == announcement.id
+ ):
+ if self.already_playing(user):
+ self.bot.loop.create_task(ctx.send(f"{user.mention} You're already playing a game!"))
+ self.bot.loop.create_task(announcement.remove_reaction(reaction, user))
+ return False
+
+ if user in self.waiting:
+ self.bot.loop.create_task(ctx.send(
+ f"{user.mention} Please cancel your game first before joining another one."
+ ))
+ self.bot.loop.create_task(announcement.remove_reaction(reaction, user))
+ return False
+
+ return True
+
+ if (
+ user.id == ctx.author.id
+ and str(reaction.emoji) == CROSS_EMOJI
+ and reaction.message.id == announcement.id
+ ):
+ return True
+ return False
+
+ def already_playing(self, player: discord.Member) -> bool:
+ """Check if someone is already in a game."""
+ return any(player in (game.p1.user, game.p2.user) for game in self.games)
+
+ @commands.group(invoke_without_command=True)
+ @commands.guild_only()
+ async def battleship(self, ctx: commands.Context) -> None:
+ """
+ Play a game of Battleship with someone else!
+
+ This will set up a message waiting for someone else to react and play along.
+ The game takes place entirely in DMs.
+ Make sure you have your DMs open so that the bot can message you.
+ """
+ if self.already_playing(ctx.author):
+ return await ctx.send("You're already playing a game!")
+
+ if ctx.author in self.waiting:
+ return await ctx.send("You've already sent out a request for a player 2")
+
+ announcement = await ctx.send(
+ "**Battleship**: A new game is about to start!\n"
+ f"Press {HAND_RAISED_EMOJI} to play against {ctx.author.mention}!\n"
+ f"(Cancel the game with {CROSS_EMOJI}.)"
+ )
+ self.waiting.append(ctx.author)
+ await announcement.add_reaction(HAND_RAISED_EMOJI)
+ await announcement.add_reaction(CROSS_EMOJI)
+
+ try:
+ reaction, user = await self.bot.wait_for(
+ "reaction_add",
+ check=partial(self.predicate, ctx, announcement),
+ timeout=60.0
+ )
+ except asyncio.TimeoutError:
+ self.waiting.remove(ctx.author)
+ await announcement.delete()
+ return await ctx.send(f"{ctx.author.mention} Seems like there's no one here to play...")
+
+ if str(reaction.emoji) == CROSS_EMOJI:
+ self.waiting.remove(ctx.author)
+ await announcement.delete()
+ return await ctx.send(f"{ctx.author.mention} Game cancelled.")
+
+ await announcement.delete()
+ self.waiting.remove(ctx.author)
+ if self.already_playing(ctx.author):
+ return
+ try:
+ game = Game(self.bot, ctx.channel, ctx.author, user)
+ self.games.append(game)
+ await game.start_game()
+ self.games.remove(game)
+ except discord.Forbidden:
+ await ctx.send(
+ f"{ctx.author.mention} {user.mention} "
+ "Game failed. This is likely due to you not having your DMs open. Check and try again."
+ )
+ self.games.remove(game)
+ except Exception:
+ # End the game in the event of an unforseen error so the players aren't stuck in a game
+ await ctx.send(f"{ctx.author.mention} {user.mention} An error occurred. Game failed")
+ self.games.remove(game)
+ raise
+
+ @battleship.command(name="ships", aliases=["boats"])
+ async def battleship_ships(self, ctx: commands.Context) -> None:
+ """Lists the ships that are found on the battleship grid."""
+ embed = discord.Embed(colour=Colours.blue)
+ embed.add_field(name="Name", value="\n".join(SHIPS))
+ embed.add_field(name="Size", value="\n".join(str(size) for size in SHIPS.values()))
+ await ctx.send(embed=embed)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Cog load."""
+ bot.add_cog(Battleship(bot))
diff --git a/bot/exts/evergreen/bookmark.py b/bot/exts/evergreen/bookmark.py
new file mode 100644
index 00000000..73908702
--- /dev/null
+++ b/bot/exts/evergreen/bookmark.py
@@ -0,0 +1,64 @@
+import logging
+import random
+
+import discord
+from discord.ext import commands
+
+from bot.constants import Colours, ERROR_REPLIES, Emojis, Icons
+
+log = logging.getLogger(__name__)
+
+
+class Bookmark(commands.Cog):
+ """Creates personal bookmarks by relaying a message link to the user's DMs."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @commands.command(name="bookmark", aliases=("bm", "pin"))
+ async def bookmark(
+ self,
+ ctx: commands.Context,
+ target_message: discord.Message,
+ *,
+ title: str = "Bookmark"
+ ) -> None:
+ """Send the author a link to `target_message` via DMs."""
+ # Prevent users from bookmarking a message in a channel they don't have access to
+ permissions = ctx.author.permissions_in(target_message.channel)
+ if not permissions.read_messages:
+ log.info(f"{ctx.author} tried to bookmark a message in #{target_message.channel} but has no permissions")
+ embed = discord.Embed(
+ title=random.choice(ERROR_REPLIES),
+ color=Colours.soft_red,
+ description="You don't have permission to view this channel."
+ )
+ await ctx.send(embed=embed)
+ return
+
+ embed = discord.Embed(
+ title=title,
+ colour=Colours.soft_green,
+ description=target_message.content
+ )
+ embed.add_field(name="Wanna give it a visit?", value=f"[Visit original message]({target_message.jump_url})")
+ embed.set_author(name=target_message.author, icon_url=target_message.author.avatar_url)
+ embed.set_thumbnail(url=Icons.bookmark)
+
+ try:
+ await ctx.author.send(embed=embed)
+ except discord.Forbidden:
+ error_embed = discord.Embed(
+ title=random.choice(ERROR_REPLIES),
+ description=f"{ctx.author.mention}, please enable your DMs to receive the bookmark",
+ colour=Colours.soft_red
+ )
+ await ctx.send(embed=error_embed)
+ else:
+ log.info(f"{ctx.author} bookmarked {target_message.jump_url} with title '{title}'")
+ await ctx.message.add_reaction(Emojis.envelope)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Load the Bookmark cog."""
+ bot.add_cog(Bookmark(bot))
diff --git a/bot/exts/evergreen/branding.py b/bot/exts/evergreen/branding.py
new file mode 100644
index 00000000..72f31042
--- /dev/null
+++ b/bot/exts/evergreen/branding.py
@@ -0,0 +1,543 @@
+import asyncio
+import itertools
+import json
+import logging
+import random
+import typing as t
+from datetime import datetime, time, timedelta
+from pathlib import Path
+
+import arrow
+import discord
+from discord.embeds import EmptyEmbed
+from discord.ext import commands
+
+from bot.bot import SeasonalBot
+from bot.constants import Branding, Colours, Emojis, MODERATION_ROLES, Tokens
+from bot.seasons import SeasonBase, get_all_seasons, get_current_season, get_season
+from bot.utils import human_months
+from bot.utils.decorators import with_role
+from bot.utils.exceptions import BrandingError
+from bot.utils.persist import make_persistent
+
+log = logging.getLogger(__name__)
+
+STATUS_OK = 200 # HTTP status code
+
+FILE_BANNER = "banner.png"
+FILE_AVATAR = "avatar.png"
+SERVER_ICONS = "server_icons"
+
+BRANDING_URL = "https://api.github.com/repos/python-discord/branding/contents"
+
+PARAMS = {"ref": "master"} # Target branch
+HEADERS = {"Accept": "application/vnd.github.v3+json"} # Ensure we use API v3
+
+# A GitHub token is not necessary for the cog to operate,
+# unauthorized requests are however limited to 60 per hour
+if Tokens.github:
+ HEADERS["Authorization"] = f"token {Tokens.github}"
+
+
+class GitHubFile(t.NamedTuple):
+ """
+ Represents a remote file on GitHub.
+
+ The `sha` hash is kept so that we can determine that a file has changed,
+ despite its filename remaining unchanged.
+ """
+
+ download_url: str
+ path: str
+ sha: str
+
+
+def pretty_files(files: t.Iterable[GitHubFile]) -> str:
+ """Provide a human-friendly representation of `files`."""
+ return "\n".join(file.path for file in files)
+
+
+def time_until_midnight() -> timedelta:
+ """
+ Determine amount of time until the next-up UTC midnight.
+
+ The exact `midnight` moment is actually delayed to 5 seconds after, in order
+ to avoid potential problems due to imprecise sleep.
+ """
+ now = datetime.utcnow()
+ tomorrow = now + timedelta(days=1)
+ midnight = datetime.combine(tomorrow, time(second=5))
+
+ return midnight - now
+
+
+class BrandingManager(commands.Cog):
+ """
+ Manages the guild's branding.
+
+ The purpose of this cog is to help automate the synchronization of the branding
+ repository with the guild. It is capable of discovering assets in the repository
+ via GitHub's API, resolving download urls for them, and delegating
+ to the `bot` instance to upload them to the guild.
+
+ BrandingManager is designed to be entirely autonomous. Its `daemon` background task awakens
+ once a day (see `time_until_midnight`) to detect new seasons, or to cycle icons within a single
+ season. The daemon can be turned on and off via the `daemon` cmd group. The value set via
+ its `start` and `stop` commands is persisted across sessions. If turned on, the daemon will
+ automatically start on the next bot start-up. Otherwise, it will wait to be started manually.
+
+ All supported operations, e.g. setting seasons, applying the branding, or cycling icons, can
+ also be invoked manually, via the following API:
+
+ branding list
+ - Show all available seasons
+
+ branding set <season_name>
+ - Set the cog's internal state to represent `season_name`, if it exists
+ - If no `season_name` is given, set chronologically current season
+ - This will not automatically apply the season's branding to the guild,
+ the cog's state can be detached from the guild
+ - Seasons can therefore be 'previewed' using this command
+
+ branding info
+ - View detailed information about resolved assets for current season
+
+ branding refresh
+ - Refresh internal state, i.e. synchronize with branding repository
+
+ branding apply
+ - Apply the current internal state to the guild, i.e. upload the assets
+
+ branding cycle
+ - If there are multiple available icons for current season, randomly pick
+ and apply the next one
+
+ The daemon calls these methods autonomously as appropriate. The use of this cog
+ is locked to moderation roles. As it performs media asset uploads, it is prone to
+ rate-limits - the `apply` command should be used with caution. The `set` command can,
+ however, be used freely to 'preview' seasonal branding and check whether paths have been
+ resolved as appropriate.
+
+ While the bot is in debug mode, it will 'mock' asset uploads by logging the passed
+ download urls and pretending that the upload was successful. Make use of this
+ to test this cog's behaviour.
+ """
+
+ current_season: t.Type[SeasonBase]
+
+ banner: t.Optional[GitHubFile]
+ avatar: t.Optional[GitHubFile]
+
+ available_icons: t.List[GitHubFile]
+ remaining_icons: t.List[GitHubFile]
+
+ days_since_cycle: t.Iterator
+
+ config_file: Path
+
+ daemon: t.Optional[asyncio.Task]
+
+ def __init__(self, bot: SeasonalBot) -> None:
+ """
+ Assign safe default values on init.
+
+ At this point, we don't have information about currently available branding.
+ Most of these attributes will be overwritten once the daemon connects, or once
+ the `refresh` command is used.
+ """
+ self.bot = bot
+ self.current_season = get_current_season()
+
+ self.banner = None
+ self.avatar = None
+
+ self.available_icons = []
+ self.remaining_icons = []
+
+ self.days_since_cycle = itertools.cycle([None])
+
+ self.config_file = make_persistent(Path("bot", "resources", "evergreen", "branding.json"))
+ should_run = self._read_config()["daemon_active"]
+
+ if should_run:
+ self.daemon = self.bot.loop.create_task(self._daemon_func())
+ else:
+ self.daemon = None
+
+ @property
+ def _daemon_running(self) -> bool:
+ """True if the daemon is currently active, False otherwise."""
+ return self.daemon is not None and not self.daemon.done()
+
+ def _read_config(self) -> t.Dict[str, bool]:
+ """Read and return persistent config file."""
+ with self.config_file.open("r") as persistent_file:
+ return json.load(persistent_file)
+
+ def _write_config(self, key: str, value: bool) -> None:
+ """Write a `key`, `value` pair to persistent config file."""
+ current_config = self._read_config()
+ current_config[key] = value
+
+ with self.config_file.open("w") as persistent_file:
+ json.dump(current_config, persistent_file)
+
+ async def _daemon_func(self) -> None:
+ """
+ Manage all automated behaviour of the BrandingManager cog.
+
+ Once a day, the daemon will perform the following tasks:
+ - Update `current_season`
+ - Poll GitHub API to see if the available branding for `current_season` has changed
+ - Update assets if changes are detected (banner, guild icon, bot avatar, bot nickname)
+ - Check whether it's time to cycle guild icons
+
+ The internal loop runs once when activated, then periodically at the time
+ given by `time_until_midnight`.
+
+ All method calls in the internal loop are considered safe, i.e. no errors propagate
+ to the daemon's loop. The daemon itself does not perform any error handling on its own.
+ """
+ await self.bot.wait_until_ready()
+
+ while True:
+ self.current_season = get_current_season()
+ branding_changed = await self.refresh()
+
+ if branding_changed:
+ await self.apply()
+
+ elif next(self.days_since_cycle) == Branding.cycle_frequency:
+ await self.cycle()
+
+ until_midnight = time_until_midnight()
+ await asyncio.sleep(until_midnight.total_seconds())
+
+ async def _info_embed(self) -> discord.Embed:
+ """Make an informative embed representing current season."""
+ info_embed = discord.Embed(description=self.current_season.description, colour=self.current_season.colour)
+
+ # If we're in a non-evergreen season, also show active months
+ if self.current_season is not SeasonBase:
+ title = f"{self.current_season.season_name} ({human_months(self.current_season.months)})"
+ else:
+ title = self.current_season.season_name
+
+ # Use the author field to show the season's name and avatar if available
+ info_embed.set_author(name=title, icon_url=self.avatar.download_url if self.avatar else EmptyEmbed)
+
+ banner = self.banner.path if self.banner is not None else "Unavailable"
+ info_embed.add_field(name="Banner", value=banner, inline=False)
+
+ avatar = self.avatar.path if self.avatar is not None else "Unavailable"
+ info_embed.add_field(name="Avatar", value=avatar, inline=False)
+
+ icons = pretty_files(self.available_icons) or "Unavailable"
+ info_embed.add_field(name="Available icons", value=icons, inline=False)
+
+ # Only display cycle frequency if we're actually cycling
+ if len(self.available_icons) > 1 and Branding.cycle_frequency:
+ info_embed.set_footer(text=f"Icon cycle frequency: {Branding.cycle_frequency}")
+
+ return info_embed
+
+ async def _reset_remaining_icons(self) -> None:
+ """Set `remaining_icons` to a shuffled copy of `available_icons`."""
+ self.remaining_icons = random.sample(self.available_icons, k=len(self.available_icons))
+
+ async def _reset_days_since_cycle(self) -> None:
+ """
+ Reset the `days_since_cycle` iterator based on configured frequency.
+
+ If the current season only has 1 icon, or if `Branding.cycle_frequency` is falsey,
+ the iterator will always yield None. This signals that the icon shouldn't be cycled.
+
+ Otherwise, it will yield ints in range [1, `Branding.cycle_frequency`] indefinitely.
+ When the iterator yields a value equal to `Branding.cycle_frequency`, it is time to cycle.
+ """
+ if len(self.available_icons) > 1 and Branding.cycle_frequency:
+ sequence = range(1, Branding.cycle_frequency + 1)
+ else:
+ sequence = [None]
+
+ self.days_since_cycle = itertools.cycle(sequence)
+
+ async def _get_files(self, path: str, include_dirs: bool = False) -> t.Dict[str, GitHubFile]:
+ """
+ Get files at `path` in the branding repository.
+
+ If `include_dirs` is False (default), only returns files at `path`.
+ Otherwise, will return both files and directories. Never returns symlinks.
+
+ Return dict mapping from filename to corresponding `GitHubFile` instance.
+ This may return an empty dict if the response status is non-200,
+ or if the target directory is empty.
+ """
+ url = f"{BRANDING_URL}/{path}"
+ async with self.bot.http_session.get(url, headers=HEADERS, params=PARAMS) as resp:
+ # Short-circuit if we get non-200 response
+ if resp.status != STATUS_OK:
+ log.error(f"GitHub API returned non-200 response: {resp}")
+ return {}
+ directory = await resp.json() # Directory at `path`
+
+ allowed_types = {"file", "dir"} if include_dirs else {"file"}
+ return {
+ file["name"]: GitHubFile(file["download_url"], file["path"], file["sha"])
+ for file in directory
+ if file["type"] in allowed_types
+ }
+
+ async def refresh(self) -> bool:
+ """
+ Synchronize available assets with branding repository.
+
+ If the current season is not the evergreen, and lacks at least one asset,
+ we use the evergreen seasonal dir as fallback for missing assets.
+
+ Finally, if neither the seasonal nor fallback branding directories contain
+ an asset, it will simply be ignored.
+
+ Return True if the branding has changed. This will be the case when we enter
+ a new season, or when something changes in the current seasons's directory
+ in the branding repository.
+ """
+ old_branding = (self.banner, self.avatar, self.available_icons)
+ seasonal_dir = await self._get_files(self.current_season.branding_path, include_dirs=True)
+
+ # Only make a call to the fallback directory if there is something to be gained
+ branding_incomplete = any(
+ asset not in seasonal_dir
+ for asset in (FILE_BANNER, FILE_AVATAR, SERVER_ICONS)
+ )
+ if branding_incomplete and self.current_season is not SeasonBase:
+ fallback_dir = await self._get_files(SeasonBase.branding_path, include_dirs=True)
+ else:
+ fallback_dir = {}
+
+ # Resolve assets in this directory, None is a safe value
+ self.banner = seasonal_dir.get(FILE_BANNER) or fallback_dir.get(FILE_BANNER)
+ self.avatar = seasonal_dir.get(FILE_AVATAR) or fallback_dir.get(FILE_AVATAR)
+
+ # Now resolve server icons by making a call to the proper sub-directory
+ if SERVER_ICONS in seasonal_dir:
+ icons_dir = await self._get_files(f"{self.current_season.branding_path}/{SERVER_ICONS}")
+ self.available_icons = list(icons_dir.values())
+
+ elif SERVER_ICONS in fallback_dir:
+ icons_dir = await self._get_files(f"{SeasonBase.branding_path}/{SERVER_ICONS}")
+ self.available_icons = list(icons_dir.values())
+
+ else:
+ self.available_icons = [] # This should never be the case, but an empty list is a safe value
+
+ # GitHubFile instances carry a `sha` attr so this will pick up if a file changes
+ branding_changed = old_branding != (self.banner, self.avatar, self.available_icons)
+
+ if branding_changed:
+ log.info(f"New branding detected (season: {self.current_season.season_name})")
+ await self._reset_remaining_icons()
+ await self._reset_days_since_cycle()
+
+ return branding_changed
+
+ async def cycle(self) -> bool:
+ """
+ Apply the next-up server icon.
+
+ Returns True if an icon is available and successfully gets applied, False otherwise.
+ """
+ if not self.available_icons:
+ log.info("Cannot cycle: no icons for this season")
+ return False
+
+ if not self.remaining_icons:
+ log.info("Reset & shuffle remaining icons")
+ await self._reset_remaining_icons()
+
+ next_up = self.remaining_icons.pop(0)
+ success = await self.bot.set_icon(next_up.download_url)
+
+ return success
+
+ async def apply(self) -> t.List[str]:
+ """
+ Apply current branding to the guild and bot.
+
+ This delegates to the bot instance to do all the work. We only provide download urls
+ for available assets. Assets unavailable in the branding repo will be ignored.
+
+ Returns a list of names of all failed assets. An asset is considered failed
+ if it isn't found in the branding repo, or if something goes wrong while the
+ bot is trying to apply it.
+
+ An empty list denotes that all assets have been applied successfully.
+ """
+ report = {asset: False for asset in ("banner", "avatar", "nickname", "icon")}
+
+ if self.banner is not None:
+ report["banner"] = await self.bot.set_banner(self.banner.download_url)
+
+ if self.avatar is not None:
+ report["avatar"] = await self.bot.set_avatar(self.avatar.download_url)
+
+ if self.current_season.bot_name:
+ report["nickname"] = await self.bot.set_nickname(self.current_season.bot_name)
+
+ report["icon"] = await self.cycle()
+
+ failed_assets = [asset for asset, succeeded in report.items() if not succeeded]
+ return failed_assets
+
+ @with_role(*MODERATION_ROLES)
+ @commands.group(name="branding")
+ async def branding_cmds(self, ctx: commands.Context) -> None:
+ """Manual branding control."""
+ if not ctx.invoked_subcommand:
+ await ctx.send_help(ctx.command)
+
+ @branding_cmds.command(name="list", aliases=["ls"])
+ async def branding_list(self, ctx: commands.Context) -> None:
+ """List all available seasons and branding sources."""
+ embed = discord.Embed(title="Available seasons", colour=Colours.soft_green)
+
+ for season in get_all_seasons():
+ if season is SeasonBase:
+ active_when = "always"
+ else:
+ active_when = f"in {human_months(season.months)}"
+
+ description = (
+ f"Active {active_when}\n"
+ f"Branding: {season.branding_path}"
+ )
+ embed.add_field(name=season.season_name, value=description, inline=False)
+
+ await ctx.send(embed=embed)
+
+ @branding_cmds.command(name="set")
+ async def branding_set(self, ctx: commands.Context, *, season_name: t.Optional[str] = None) -> None:
+ """
+ Manually set season, or reset to current if none given.
+
+ Season search is a case-less comparison against both seasonal class name,
+ and its `season_name` attr.
+
+ This only pre-loads the cog's internal state to the chosen season, but does not
+ automatically apply the branding. As that is an expensive operation, the `apply`
+ command must be called explicitly after this command finishes.
+
+ This means that this command can be used to 'preview' a season gathering info
+ about its available assets, without applying them to the guild.
+
+ If the daemon is running, it will automatically reset the season to current when
+ it wakes up. The season set via this command can therefore remain 'detached' from
+ what it should be - the daemon will make sure that it's set back properly.
+ """
+ if season_name is None:
+ new_season = get_current_season()
+ else:
+ new_season = get_season(season_name)
+ if new_season is None:
+ raise BrandingError("No such season exists")
+
+ if self.current_season is new_season:
+ raise BrandingError(f"Season {self.current_season.season_name} already active")
+
+ self.current_season = new_season
+ await self.branding_refresh(ctx)
+
+ @branding_cmds.command(name="info", aliases=["status"])
+ async def branding_info(self, ctx: commands.Context) -> None:
+ """
+ Show available assets for current season.
+
+ This can be used to confirm that assets have been resolved properly.
+ When `apply` is used, it attempts to upload exactly the assets listed here.
+ """
+ await ctx.send(embed=await self._info_embed())
+
+ @branding_cmds.command(name="refresh")
+ async def branding_refresh(self, ctx: commands.Context) -> None:
+ """Sync currently available assets with branding repository."""
+ async with ctx.typing():
+ await self.refresh()
+ await self.branding_info(ctx)
+
+ @branding_cmds.command(name="apply")
+ async def branding_apply(self, ctx: commands.Context) -> None:
+ """
+ Apply current season's branding to the guild.
+
+ Use `info` to check which assets will be applied. Shows which assets have
+ failed to be applied, if any.
+ """
+ async with ctx.typing():
+ failed_assets = await self.apply()
+ if failed_assets:
+ raise BrandingError(f"Failed to apply following assets: {', '.join(failed_assets)}")
+
+ response = discord.Embed(description=f"All assets applied {Emojis.ok_hand}", colour=Colours.soft_green)
+ await ctx.send(embed=response)
+
+ @branding_cmds.command(name="cycle")
+ async def branding_cycle(self, ctx: commands.Context) -> None:
+ """
+ Apply the next-up guild icon, if multiple are available.
+
+ The order is random.
+ """
+ async with ctx.typing():
+ success = await self.cycle()
+ if not success:
+ raise BrandingError("Failed to cycle icon")
+
+ response = discord.Embed(description=f"Success {Emojis.ok_hand}", colour=Colours.soft_green)
+ await ctx.send(embed=response)
+
+ @branding_cmds.group(name="daemon", aliases=["d", "task"])
+ async def daemon_group(self, ctx: commands.Context) -> None:
+ """Control the background daemon."""
+ if not ctx.invoked_subcommand:
+ await ctx.send_help(ctx.command)
+
+ @daemon_group.command(name="status")
+ async def daemon_status(self, ctx: commands.Context) -> None:
+ """Check whether daemon is currently active."""
+ if self._daemon_running:
+ remaining_time = (arrow.utcnow() + time_until_midnight()).humanize()
+ response = discord.Embed(description=f"Daemon running {Emojis.ok_hand}", colour=Colours.soft_green)
+ response.set_footer(text=f"Next refresh {remaining_time}")
+ else:
+ response = discord.Embed(description="Daemon not running", colour=Colours.soft_red)
+
+ await ctx.send(embed=response)
+
+ @daemon_group.command(name="start")
+ async def daemon_start(self, ctx: commands.Context) -> None:
+ """If the daemon isn't running, start it."""
+ if self._daemon_running:
+ raise BrandingError("Daemon already running!")
+
+ self.daemon = self.bot.loop.create_task(self._daemon_func())
+ self._write_config("daemon_active", True)
+
+ response = discord.Embed(description=f"Daemon started {Emojis.ok_hand}", colour=Colours.soft_green)
+ await ctx.send(embed=response)
+
+ @daemon_group.command(name="stop")
+ async def daemon_stop(self, ctx: commands.Context) -> None:
+ """If the daemon is running, stop it."""
+ if not self._daemon_running:
+ raise BrandingError("Daemon not running!")
+
+ self.daemon.cancel()
+ self._write_config("daemon_active", False)
+
+ response = discord.Embed(description=f"Daemon stopped {Emojis.ok_hand}", colour=Colours.soft_green)
+ await ctx.send(embed=response)
+
+
+def setup(bot: SeasonalBot) -> None:
+ """Load BrandingManager cog."""
+ bot.add_cog(BrandingManager(bot))
diff --git a/bot/exts/evergreen/error_handler.py b/bot/exts/evergreen/error_handler.py
new file mode 100644
index 00000000..33b1a3f2
--- /dev/null
+++ b/bot/exts/evergreen/error_handler.py
@@ -0,0 +1,129 @@
+import logging
+import math
+import random
+from typing import Iterable, Union
+
+from discord import Embed, Message
+from discord.ext import commands
+from sentry_sdk import push_scope
+
+from bot.constants import Colours, ERROR_REPLIES, NEGATIVE_REPLIES
+from bot.utils.decorators import InChannelCheckFailure, InMonthCheckFailure
+from bot.utils.exceptions import BrandingError
+
+log = logging.getLogger(__name__)
+
+
+class CommandErrorHandler(commands.Cog):
+ """A error handler for the PythonDiscord server."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @staticmethod
+ def revert_cooldown_counter(command: commands.Command, message: Message) -> None:
+ """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.")
+
+ @staticmethod
+ def error_embed(message: str, title: Union[Iterable, str] = ERROR_REPLIES) -> Embed:
+ """Build a basic embed with red colour and either a random error title or a title provided."""
+ embed = Embed(colour=Colours.soft_red)
+ if isinstance(title, str):
+ embed.title = title
+ else:
+ embed.title = random.choice(title)
+ embed.description = message
+ return embed
+
+ @commands.Cog.listener()
+ async def on_command_error(self, ctx: commands.Context, error: commands.CommandError) -> None:
+ """Activates when a command opens an error."""
+ if hasattr(ctx.command, 'on_error'):
+ logging.debug("A command error occured but the command had it's own error handler.")
+ return
+
+ error = getattr(error, 'original', error)
+ logging.debug(
+ f"Error Encountered: {type(error).__name__} - {str(error)}, "
+ f"Command: {ctx.command}, "
+ f"Author: {ctx.author}, "
+ f"Channel: {ctx.channel}"
+ )
+
+ if isinstance(error, commands.CommandNotFound):
+ return
+
+ if isinstance(error, BrandingError):
+ await ctx.send(embed=self.error_embed(str(error)))
+ return
+
+ if isinstance(error, (InChannelCheckFailure, InMonthCheckFailure)):
+ await ctx.send(embed=self.error_embed(str(error), NEGATIVE_REPLIES), delete_after=7.5)
+ return
+
+ if isinstance(error, commands.UserInputError):
+ self.revert_cooldown_counter(ctx.command, ctx.message)
+ embed = self.error_embed(
+ f"Your input was invalid: {error}\n\nUsage:\n```{ctx.prefix}{ctx.command} {ctx.command.signature}```"
+ )
+ await ctx.send(embed=embed)
+ return
+
+ if isinstance(error, commands.CommandOnCooldown):
+ mins, secs = divmod(math.ceil(error.retry_after), 60)
+ embed = self.error_embed(
+ f"This command is on cooldown:\nPlease retry in {mins} minutes {secs} seconds.",
+ NEGATIVE_REPLIES
+ )
+ await ctx.send(embed=embed, delete_after=7.5)
+ return
+
+ if isinstance(error, commands.DisabledCommand):
+ await ctx.send(embed=self.error_embed("This command has been disabled.", NEGATIVE_REPLIES))
+ return
+
+ if isinstance(error, commands.NoPrivateMessage):
+ await ctx.send(embed=self.error_embed("This command can only be used in the server.", NEGATIVE_REPLIES))
+ return
+
+ if isinstance(error, commands.BadArgument):
+ self.revert_cooldown_counter(ctx.command, ctx.message)
+ embed = self.error_embed(
+ "The argument you provided was invalid: "
+ f"{error}\n\nUsage:\n```{ctx.prefix}{ctx.command} {ctx.command.signature}```"
+ )
+ await ctx.send(embed=embed)
+ return
+
+ if isinstance(error, commands.CheckFailure):
+ await ctx.send(embed=self.error_embed("You are not authorized to use this command.", NEGATIVE_REPLIES))
+ return
+
+ with push_scope() as scope:
+ scope.user = {
+ "id": ctx.author.id,
+ "username": str(ctx.author)
+ }
+
+ scope.set_tag("command", ctx.command.qualified_name)
+ scope.set_tag("message_id", ctx.message.id)
+ scope.set_tag("channel_id", ctx.channel.id)
+
+ scope.set_extra("full_message", ctx.message.content)
+
+ if ctx.guild is not None:
+ scope.set_extra(
+ "jump_to",
+ f"https://discordapp.com/channels/{ctx.guild.id}/{ctx.channel.id}/{ctx.message.id}"
+ )
+
+ log.exception(f"Unhandled command error: {str(error)}", exc_info=error)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Error handler Cog load."""
+ bot.add_cog(CommandErrorHandler(bot))
diff --git a/bot/exts/evergreen/fun.py b/bot/exts/evergreen/fun.py
new file mode 100644
index 00000000..67a4bae5
--- /dev/null
+++ b/bot/exts/evergreen/fun.py
@@ -0,0 +1,147 @@
+import functools
+import logging
+import random
+from typing import Callable, Tuple, Union
+
+from discord import Embed, Message
+from discord.ext import commands
+from discord.ext.commands import Bot, Cog, Context, MessageConverter
+
+from bot import utils
+from bot.constants import Emojis
+
+log = logging.getLogger(__name__)
+
+UWU_WORDS = {
+ "fi": "fwi",
+ "l": "w",
+ "r": "w",
+ "some": "sum",
+ "th": "d",
+ "thing": "fing",
+ "tho": "fo",
+ "you're": "yuw'we",
+ "your": "yur",
+ "you": "yuw",
+}
+
+
+class Fun(Cog):
+ """A collection of general commands for fun."""
+
+ def __init__(self, bot: Bot) -> None:
+ self.bot = bot
+
+ @commands.command()
+ async def roll(self, ctx: Context, num_rolls: int = 1) -> None:
+ """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)
+
+ @commands.command(name="uwu", aliases=("uwuwize", "uwuify",))
+ async def uwu_command(self, ctx: Context, *, text: str) -> None:
+ """
+ Converts a given `text` into it's uwu equivalent.
+
+ Also accepts a valid discord Message ID or link.
+ """
+ conversion_func = functools.partial(
+ utils.replace_many, replacements=UWU_WORDS, ignore_case=True, match_case=True
+ )
+ text, embed = await Fun._get_text_and_embed(ctx, text)
+ # Convert embed if it exists
+ if embed is not None:
+ embed = Fun._convert_embed(conversion_func, embed)
+ converted_text = conversion_func(text)
+ # Don't put >>> if only embed present
+ if converted_text:
+ converted_text = f">>> {converted_text.lstrip('> ')}"
+ await ctx.send(content=converted_text, embed=embed)
+
+ @commands.command(name="randomcase", aliases=("rcase", "randomcaps", "rcaps",))
+ async def randomcase_command(self, ctx: Context, *, text: str) -> None:
+ """
+ Randomly converts the casing of a given `text`.
+
+ Also accepts a valid discord Message ID or link.
+ """
+ def conversion_func(text: str) -> str:
+ """Randomly converts the casing of a given string."""
+ return "".join(
+ char.upper() if round(random.random()) else char.lower() for char in text
+ )
+ text, embed = await Fun._get_text_and_embed(ctx, text)
+ # Convert embed if it exists
+ if embed is not None:
+ embed = Fun._convert_embed(conversion_func, embed)
+ converted_text = conversion_func(text)
+ # Don't put >>> if only embed present
+ if converted_text:
+ converted_text = f">>> {converted_text.lstrip('> ')}"
+ await ctx.send(content=converted_text, embed=embed)
+
+ @staticmethod
+ async def _get_text_and_embed(ctx: Context, text: str) -> Tuple[str, Union[Embed, None]]:
+ """
+ Attempts to extract the text and embed from a possible link to a discord Message.
+
+ Returns a tuple of:
+ str: If `text` is a valid discord Message, the contents of the message, else `text`.
+ Union[Embed, None]: The embed if found in the valid Message, else None
+ """
+ embed = None
+ message = await Fun._get_discord_message(ctx, text)
+ if isinstance(message, Message):
+ text = message.content
+ # Take first embed because we can't send multiple embeds
+ if message.embeds:
+ embed = message.embeds[0]
+ return (text, embed)
+
+ @staticmethod
+ async def _get_discord_message(ctx: Context, text: str) -> Union[Message, str]:
+ """
+ Attempts to convert a given `text` to a discord Message object and return it.
+
+ Conversion will succeed if given a discord Message ID or link.
+ Returns `text` if the conversion fails.
+ """
+ try:
+ text = await MessageConverter().convert(ctx, text)
+ except commands.BadArgument:
+ log.debug(f"Input '{text:.20}...' is not a valid Discord Message")
+ return text
+
+ @staticmethod
+ def _convert_embed(func: Callable[[str, ], str], embed: Embed) -> Embed:
+ """
+ Converts the text in an embed using a given conversion function, then return the embed.
+
+ Only modifies the following fields: title, description, footer, fields
+ """
+ embed_dict = embed.to_dict()
+
+ embed_dict["title"] = func(embed_dict.get("title", ""))
+ embed_dict["description"] = func(embed_dict.get("description", ""))
+
+ if "footer" in embed_dict:
+ embed_dict["footer"]["text"] = func(embed_dict["footer"].get("text", ""))
+
+ if "fields" in embed_dict:
+ for field in embed_dict["fields"]:
+ field["name"] = func(field.get("name", ""))
+ field["value"] = func(field.get("value", ""))
+
+ return Embed.from_dict(embed_dict)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Fun Cog load."""
+ bot.add_cog(Fun(bot))
diff --git a/bot/exts/evergreen/game.py b/bot/exts/evergreen/game.py
new file mode 100644
index 00000000..3c8b2725
--- /dev/null
+++ b/bot/exts/evergreen/game.py
@@ -0,0 +1,424 @@
+import difflib
+import logging
+import random
+import re
+from datetime import datetime as dt
+from enum import IntEnum
+from typing import Any, Dict, List, Optional, Tuple
+
+from aiohttp import ClientSession
+from discord import Embed
+from discord.ext import tasks
+from discord.ext.commands import Cog, Context, group
+
+from bot.bot import SeasonalBot
+from bot.constants import STAFF_ROLES, Tokens
+from bot.utils.decorators import with_role
+from bot.utils.pagination import ImagePaginator, LinePaginator
+
+# Base URL of IGDB API
+BASE_URL = "https://api-v3.igdb.com"
+
+HEADERS = {
+ "user-key": Tokens.igdb,
+ "Accept": "application/json"
+}
+
+logger = logging.getLogger(__name__)
+
+REGEX_NON_ALPHABET = re.compile(r"[^a-z0-9]", re.IGNORECASE)
+
+# ---------
+# TEMPLATES
+# ---------
+
+# Body templates
+# Request body template for get_games_list
+GAMES_LIST_BODY = (
+ "fields cover.image_id, first_release_date, total_rating, name, storyline, url, platforms.name, status,"
+ "involved_companies.company.name, summary, age_ratings.category, age_ratings.rating, total_rating_count;"
+ "{sort} {limit} {offset} {genre} {additional}"
+)
+
+# Request body template for get_companies_list
+COMPANIES_LIST_BODY = (
+ "fields name, url, start_date, logo.image_id, developed.name, published.name, description;"
+ "offset {offset}; limit {limit};"
+)
+
+# Request body template for games search
+SEARCH_BODY = 'fields name, url, storyline, total_rating, total_rating_count; limit 50; search "{term}";'
+
+# Pages templates
+# Game embed layout
+GAME_PAGE = (
+ "**[{name}]({url})**\n"
+ "{description}"
+ "**Release Date:** {release_date}\n"
+ "**Rating:** {rating}/100 :star: (based on {rating_count} ratings)\n"
+ "**Platforms:** {platforms}\n"
+ "**Status:** {status}\n"
+ "**Age Ratings:** {age_ratings}\n"
+ "**Made by:** {made_by}\n\n"
+ "{storyline}"
+)
+
+# .games company command page layout
+COMPANY_PAGE = (
+ "**[{name}]({url})**\n"
+ "{description}"
+ "**Founded:** {founded}\n"
+ "**Developed:** {developed}\n"
+ "**Published:** {published}"
+)
+
+# For .games search command line layout
+GAME_SEARCH_LINE = (
+ "**[{name}]({url})**\n"
+ "{rating}/100 :star: (based on {rating_count} ratings)\n"
+)
+
+# URL templates
+COVER_URL = "https://images.igdb.com/igdb/image/upload/t_cover_big/{image_id}.jpg"
+LOGO_URL = "https://images.igdb.com/igdb/image/upload/t_logo_med/{image_id}.png"
+
+# Create aliases for complex genre names
+ALIASES = {
+ "Role-playing (rpg)": ["Role playing", "Rpg"],
+ "Turn-based strategy (tbs)": ["Turn based strategy", "Tbs"],
+ "Real time strategy (rts)": ["Real time strategy", "Rts"],
+ "Hack and slash/beat 'em up": ["Hack and slash"]
+}
+
+
+class GameStatus(IntEnum):
+ """Game statuses in IGDB API."""
+
+ Released = 0
+ Alpha = 2
+ Beta = 3
+ Early = 4
+ Offline = 5
+ Cancelled = 6
+ Rumored = 7
+
+
+class AgeRatingCategories(IntEnum):
+ """IGDB API Age Rating categories IDs."""
+
+ ESRB = 1
+ PEGI = 2
+
+
+class AgeRatings(IntEnum):
+ """PEGI/ESRB ratings IGDB API IDs."""
+
+ Three = 1
+ Seven = 2
+ Twelve = 3
+ Sixteen = 4
+ Eighteen = 5
+ RP = 6
+ EC = 7
+ E = 8
+ E10 = 9
+ T = 10
+ M = 11
+ AO = 12
+
+
+class Games(Cog):
+ """Games Cog contains commands that collect data from IGDB."""
+
+ def __init__(self, bot: SeasonalBot):
+ self.bot = bot
+ self.http_session: ClientSession = bot.http_session
+
+ self.genres: Dict[str, int] = {}
+
+ self.refresh_genres_task.start()
+
+ @tasks.loop(hours=24.0)
+ async def refresh_genres_task(self) -> None:
+ """Refresh genres in every hour."""
+ try:
+ await self._get_genres()
+ except Exception as e:
+ logger.warning(f"There was error while refreshing genres: {e}")
+ return
+ logger.info("Successfully refreshed genres.")
+
+ def cog_unload(self) -> None:
+ """Cancel genres refreshing start when unloading Cog."""
+ self.refresh_genres_task.cancel()
+ logger.info("Successfully stopped Genres Refreshing task.")
+
+ async def _get_genres(self) -> None:
+ """Create genres variable for games command."""
+ body = "fields name; limit 100;"
+ async with self.http_session.get(f"{BASE_URL}/genres", data=body, headers=HEADERS) as resp:
+ result = await resp.json()
+
+ genres = {genre["name"].capitalize(): genre["id"] for genre in result}
+
+ # Replace complex names with names from ALIASES
+ for genre_name, genre in genres.items():
+ if genre_name in ALIASES:
+ for alias in ALIASES[genre_name]:
+ self.genres[alias] = genre
+ else:
+ self.genres[genre_name] = genre
+
+ @group(name="games", aliases=["game"], invoke_without_command=True)
+ async def games(self, ctx: Context, amount: Optional[int] = 5, *, genre: Optional[str] = None) -> None:
+ """
+ Get random game(s) by genre from IGDB. Use .games genres command to get all available genres.
+
+ Also support amount parameter, what max is 25 and min 1, default 5. Supported formats:
+ - .games <genre>
+ - .games <amount> <genre>
+ """
+ # When user didn't specified genre, send help message
+ if genre is None:
+ await ctx.send_help("games")
+ return
+
+ # Capitalize genre for check
+ genre = "".join(genre).capitalize()
+
+ # Check for amounts, max is 25 and min 1
+ if not 1 <= amount <= 25:
+ await ctx.send("Your provided amount is out of range. Our minimum is 1 and maximum 25.")
+ return
+
+ # Get games listing, if genre don't exist, show error message with possibilities.
+ # Offset must be random, due otherwise we will get always same result (offset show in which position should
+ # API start returning result)
+ try:
+ games = await self.get_games_list(amount, self.genres[genre], offset=random.randint(0, 150))
+ except KeyError:
+ possibilities = await self.get_best_results(genre)
+ # If there is more than 1 possibilities, show these.
+ # If there is only 1 possibility, use it as genre.
+ # Otherwise send message about invalid genre.
+ if len(possibilities) > 1:
+ display_possibilities = "`, `".join(p[1] for p in possibilities)
+ await ctx.send(
+ f"Invalid genre `{genre}`. "
+ f"{f'Maybe you meant `{display_possibilities}`?' if display_possibilities else ''}"
+ )
+ return
+ elif len(possibilities) == 1:
+ games = await self.get_games_list(
+ amount, self.genres[possibilities[0][1]], offset=random.randint(0, 150)
+ )
+ genre = possibilities[0][1]
+ else:
+ await ctx.send(f"Invalid genre `{genre}`.")
+ return
+
+ # Create pages and paginate
+ pages = [await self.create_page(game) for game in games]
+
+ await ImagePaginator.paginate(pages, ctx, Embed(title=f"Random {genre.title()} Games"))
+
+ @games.command(name="top", aliases=["t"])
+ async def top(self, ctx: Context, amount: int = 10) -> None:
+ """
+ Get current Top games in IGDB.
+
+ Support amount parameter. Max is 25, min is 1.
+ """
+ if not 1 <= amount <= 25:
+ await ctx.send("Your provided amount is out of range. Our minimum is 1 and maximum 25.")
+ return
+
+ games = await self.get_games_list(amount, sort="total_rating desc",
+ additional_body="where total_rating >= 90; sort total_rating_count desc;")
+
+ pages = [await self.create_page(game) for game in games]
+ await ImagePaginator.paginate(pages, ctx, Embed(title=f"Top {amount} Games"))
+
+ @games.command(name="genres", aliases=["genre", "g"])
+ async def genres(self, ctx: Context) -> None:
+ """Get all available genres."""
+ await ctx.send(f"Currently available genres: {', '.join(f'`{genre}`' for genre in self.genres)}")
+
+ @games.command(name="search", aliases=["s"])
+ async def search(self, ctx: Context, *, search_term: str) -> None:
+ """Find games by name."""
+ lines = await self.search_games(search_term)
+
+ await LinePaginator.paginate(lines, ctx, Embed(title=f"Game Search Results: {search_term}"), empty=False)
+
+ @games.command(name="company", aliases=["companies"])
+ async def company(self, ctx: Context, amount: int = 5) -> None:
+ """
+ Get random Game Companies companies from IGDB API.
+
+ Support amount parameter. Max is 25, min is 1.
+ """
+ if not 1 <= amount <= 25:
+ await ctx.send("Your provided amount is out of range. Our minimum is 1 and maximum 25.")
+ return
+
+ # Get companies listing. Provide limit for limiting how much companies will be returned. Get random offset to
+ # get (almost) every time different companies (offset show in which position should API start returning result)
+ companies = await self.get_companies_list(limit=amount, offset=random.randint(0, 150))
+ pages = [await self.create_company_page(co) for co in companies]
+
+ await ImagePaginator.paginate(pages, ctx, Embed(title="Random Game Companies"))
+
+ @with_role(*STAFF_ROLES)
+ @games.command(name="refresh", aliases=["r"])
+ async def refresh_genres_command(self, ctx: Context) -> None:
+ """Refresh .games command genres."""
+ try:
+ await self._get_genres()
+ except Exception as e:
+ await ctx.send(f"There was error while refreshing genres: `{e}`")
+ return
+ await ctx.send("Successfully refreshed genres.")
+
+ async def get_games_list(self,
+ amount: int,
+ genre: Optional[str] = None,
+ sort: Optional[str] = None,
+ additional_body: str = "",
+ offset: int = 0
+ ) -> List[Dict[str, Any]]:
+ """
+ Get list of games from IGDB API by parameters that is provided.
+
+ Amount param show how much games this get, genre is genre ID and at least one genre in game must this when
+ provided. Sort is sorting by specific field and direction, ex. total_rating desc/asc (total_rating is field,
+ desc/asc is direction). Additional_body is field where you can pass extra search parameters. Offset show start
+ position in API.
+ """
+ # Create body of IGDB API request, define fields, sorting, offset, limit and genre
+ params = {
+ "sort": f"sort {sort};" if sort else "",
+ "limit": f"limit {amount};",
+ "offset": f"offset {offset};" if offset else "",
+ "genre": f"where genres = ({genre});" if genre else "",
+ "additional": additional_body
+ }
+ body = GAMES_LIST_BODY.format(**params)
+
+ # Do request to IGDB API, create headers, URL, define body, return result
+ async with self.http_session.get(url=f"{BASE_URL}/games", data=body, headers=HEADERS) as resp:
+ return await resp.json()
+
+ async def create_page(self, data: Dict[str, Any]) -> Tuple[str, str]:
+ """Create content of Game Page."""
+ # Create cover image URL from template
+ url = COVER_URL.format(**{"image_id": data["cover"]["image_id"] if "cover" in data else ""})
+
+ # Get release date separately with checking
+ release_date = dt.utcfromtimestamp(data["first_release_date"]).date() if "first_release_date" in data else "?"
+
+ # Create Age Ratings value
+ rating = ", ".join(f"{AgeRatingCategories(age['category']).name} {AgeRatings(age['rating']).name}"
+ for age in data["age_ratings"]) if "age_ratings" in data else "?"
+
+ companies = [c["company"]["name"] for c in data["involved_companies"]] if "involved_companies" in data else "?"
+
+ # Create formatting for template page
+ formatting = {
+ "name": data["name"],
+ "url": data["url"],
+ "description": f"{data['summary']}\n\n" if "summary" in data else "\n",
+ "release_date": release_date,
+ "rating": round(data["total_rating"] if "total_rating" in data else 0, 2),
+ "rating_count": data["total_rating_count"] if "total_rating_count" in data else "?",
+ "platforms": ", ".join(platform["name"] for platform in data["platforms"]) if "platforms" in data else "?",
+ "status": GameStatus(data["status"]).name if "status" in data else "?",
+ "age_ratings": rating,
+ "made_by": ", ".join(companies),
+ "storyline": data["storyline"] if "storyline" in data else ""
+ }
+ page = GAME_PAGE.format(**formatting)
+
+ return page, url
+
+ async def search_games(self, search_term: str) -> List[str]:
+ """Search game from IGDB API by string, return listing of pages."""
+ lines = []
+
+ # Define request body of IGDB API request and do request
+ body = SEARCH_BODY.format(**{"term": search_term})
+
+ async with self.http_session.get(url=f"{BASE_URL}/games", data=body, headers=HEADERS) as resp:
+ data = await resp.json()
+
+ # Loop over games, format them to good format, make line and append this to total lines
+ for game in data:
+ formatting = {
+ "name": game["name"],
+ "url": game["url"],
+ "rating": round(game["total_rating"] if "total_rating" in game else 0, 2),
+ "rating_count": game["total_rating_count"] if "total_rating" in game else "?"
+ }
+ line = GAME_SEARCH_LINE.format(**formatting)
+ lines.append(line)
+
+ return lines
+
+ async def get_companies_list(self, limit: int, offset: int = 0) -> List[Dict[str, Any]]:
+ """
+ Get random Game Companies from IGDB API.
+
+ Limit is parameter, that show how much movies this should return, offset show in which position should API start
+ returning results.
+ """
+ # Create request body from template
+ body = COMPANIES_LIST_BODY.format(**{
+ "limit": limit,
+ "offset": offset
+ })
+
+ async with self.http_session.get(url=f"{BASE_URL}/companies", data=body, headers=HEADERS) as resp:
+ return await resp.json()
+
+ async def create_company_page(self, data: Dict[str, Any]) -> Tuple[str, str]:
+ """Create good formatted Game Company page."""
+ # Generate URL of company logo
+ url = LOGO_URL.format(**{"image_id": data["logo"]["image_id"] if "logo" in data else ""})
+
+ # Try to get found date of company
+ founded = dt.utcfromtimestamp(data["start_date"]).date() if "start_date" in data else "?"
+
+ # Generate list of games, that company have developed or published
+ developed = ", ".join(game["name"] for game in data["developed"]) if "developed" in data else "?"
+ published = ", ".join(game["name"] for game in data["published"]) if "published" in data else "?"
+
+ formatting = {
+ "name": data["name"],
+ "url": data["url"],
+ "description": f"{data['description']}\n\n" if "description" in data else "\n",
+ "founded": founded,
+ "developed": developed,
+ "published": published
+ }
+ page = COMPANY_PAGE.format(**formatting)
+
+ return page, url
+
+ async def get_best_results(self, query: str) -> List[Tuple[float, str]]:
+ """Get best match result of genre when original genre is invalid."""
+ results = []
+ for genre in self.genres:
+ ratios = [difflib.SequenceMatcher(None, query, genre).ratio()]
+ for word in REGEX_NON_ALPHABET.split(genre):
+ ratios.append(difflib.SequenceMatcher(None, query, word).ratio())
+ results.append((round(max(ratios), 2), genre))
+ return sorted((item for item in results if item[0] >= 0.60), reverse=True)[:4]
+
+
+def setup(bot: SeasonalBot) -> None:
+ """Add/Load Games cog."""
+ # Check does IGDB API key exist, if not, log warning and don't load cog
+ if not Tokens.igdb:
+ logger.warning("No IGDB API key. Not loading Games cog.")
+ return
+ bot.add_cog(Games(bot))
diff --git a/bot/exts/evergreen/help.py b/bot/exts/evergreen/help.py
new file mode 100644
index 00000000..ccd76d76
--- /dev/null
+++ b/bot/exts/evergreen/help.py
@@ -0,0 +1,552 @@
+# Help command from Python bot. All commands that will be added to there in futures should be added to here too.
+import asyncio
+import itertools
+import logging
+from collections import namedtuple
+from contextlib import suppress
+from typing import Union
+
+from discord import Colour, Embed, HTTPException, Message, Reaction, User
+from discord.ext import commands
+from discord.ext.commands import CheckFailure, Cog as DiscordCog, Command, Context
+from fuzzywuzzy import fuzz, process
+
+from bot import constants
+from bot.bot import SeasonalBot
+from bot.constants import Emojis
+from bot.utils.pagination import (
+ FIRST_EMOJI, LAST_EMOJI,
+ LEFT_EMOJI, LinePaginator, RIGHT_EMOJI,
+)
+
+DELETE_EMOJI = Emojis.trashcan
+
+REACTIONS = {
+ FIRST_EMOJI: 'first',
+ LEFT_EMOJI: 'back',
+ RIGHT_EMOJI: 'next',
+ LAST_EMOJI: 'end',
+ DELETE_EMOJI: 'stop',
+}
+
+Cog = namedtuple('Cog', ['name', 'description', 'commands'])
+
+log = logging.getLogger(__name__)
+
+
+class HelpQueryNotFound(ValueError):
+ """
+ Raised when a HelpSession Query doesn't match a command or cog.
+
+ Contains the custom attribute of ``possible_matches``.
+ Instances of this object contain a dictionary of any command(s) that were close to matching the
+ query, where keys are the possible matched command names and values are the likeness match scores.
+ """
+
+ def __init__(self, arg: str, possible_matches: dict = None):
+ super().__init__(arg)
+ self.possible_matches = possible_matches
+
+
+class HelpSession:
+ """
+ An interactive session for bot and command help output.
+
+ Expected attributes include:
+ * title: str
+ The title of the help message.
+ * query: Union[discord.ext.commands.Bot, discord.ext.commands.Command]
+ * description: str
+ The description of the query.
+ * pages: list[str]
+ A list of the help content split into manageable pages.
+ * message: `discord.Message`
+ The message object that's showing the help contents.
+ * destination: `discord.abc.Messageable`
+ Where the help message is to be sent to.
+ Cogs can be grouped into custom categories. All cogs with the same category will be displayed
+ under a single category name in the help output. Custom categories are defined inside the cogs
+ as a class attribute named `category`. A description can also be specified with the attribute
+ `category_description`. If a description is not found in at least one cog, the default will be
+ the regular description (class docstring) of the first cog found in the category.
+ """
+
+ def __init__(
+ self,
+ ctx: Context,
+ *command,
+ cleanup: bool = False,
+ only_can_run: bool = True,
+ show_hidden: bool = False,
+ max_lines: int = 15
+ ):
+ """Creates an instance of the HelpSession class."""
+ self._ctx = ctx
+ self._bot = ctx.bot
+ self.title = "Command Help"
+
+ # set the query details for the session
+ if command:
+ query_str = ' '.join(command)
+ self.query = self._get_query(query_str)
+ self.description = self.query.description or self.query.help
+ else:
+ self.query = ctx.bot
+ self.description = self.query.description
+ self.author = ctx.author
+ self.destination = ctx.channel
+
+ # set the config for the session
+ self._cleanup = cleanup
+ self._only_can_run = only_can_run
+ self._show_hidden = show_hidden
+ self._max_lines = max_lines
+
+ # init session states
+ self._pages = None
+ self._current_page = 0
+ self.message = None
+ self._timeout_task = None
+ self.reset_timeout()
+
+ def _get_query(self, query: str) -> Union[Command, Cog]:
+ """Attempts to match the provided query with a valid command or cog."""
+ command = self._bot.get_command(query)
+ if command:
+ return command
+
+ # Find all cog categories that match.
+ cog_matches = []
+ description = None
+ for cog in self._bot.cogs.values():
+ if hasattr(cog, "category") and cog.category == query:
+ cog_matches.append(cog)
+ if hasattr(cog, "category_description"):
+ description = cog.category_description
+
+ # Try to search by cog name if no categories match.
+ if not cog_matches:
+ cog = self._bot.cogs.get(query)
+
+ # Don't consider it a match if the cog has a category.
+ if cog and not hasattr(cog, "category"):
+ cog_matches = [cog]
+
+ if cog_matches:
+ cog = cog_matches[0]
+ cmds = (cog.get_commands() for cog in cog_matches) # Commands of all cogs
+
+ return Cog(
+ name=cog.category if hasattr(cog, "category") else cog.qualified_name,
+ description=description or cog.description,
+ commands=tuple(itertools.chain.from_iterable(cmds)) # Flatten the list
+ )
+
+ self._handle_not_found(query)
+
+ def _handle_not_found(self, query: str) -> None:
+ """
+ Handles when a query does not match a valid command or cog.
+
+ Will pass on possible close matches along with the `HelpQueryNotFound` exception.
+ """
+ # Combine command and cog names
+ choices = list(self._bot.all_commands) + list(self._bot.cogs)
+
+ result = process.extractBests(query, choices, scorer=fuzz.ratio, score_cutoff=90)
+
+ raise HelpQueryNotFound(f'Query "{query}" not found.', dict(result))
+
+ async def timeout(self, seconds: int = 30) -> None:
+ """Waits for a set number of seconds, then stops the help session."""
+ await asyncio.sleep(seconds)
+ await self.stop()
+
+ def reset_timeout(self) -> None:
+ """Cancels the original timeout task and sets it again from the start."""
+ # cancel original if it exists
+ if self._timeout_task:
+ if not self._timeout_task.cancelled():
+ self._timeout_task.cancel()
+
+ # recreate the timeout task
+ self._timeout_task = self._bot.loop.create_task(self.timeout())
+
+ async def on_reaction_add(self, reaction: Reaction, user: User) -> None:
+ """Event handler for when reactions are added on the help message."""
+ # ensure it was the relevant session message
+ if reaction.message.id != self.message.id:
+ return
+
+ # ensure it was the session author who reacted
+ if user.id != self.author.id:
+ return
+
+ emoji = str(reaction.emoji)
+
+ # check if valid action
+ if emoji not in REACTIONS:
+ return
+
+ self.reset_timeout()
+
+ # Run relevant action method
+ action = getattr(self, f'do_{REACTIONS[emoji]}', None)
+ if action:
+ await action()
+
+ # remove the added reaction to prep for re-use
+ with suppress(HTTPException):
+ await self.message.remove_reaction(reaction, user)
+
+ async def on_message_delete(self, message: Message) -> None:
+ """Closes the help session when the help message is deleted."""
+ if message.id == self.message.id:
+ await self.stop()
+
+ async def prepare(self) -> None:
+ """Sets up the help session pages, events, message and reactions."""
+ await self.build_pages()
+
+ self._bot.add_listener(self.on_reaction_add)
+ self._bot.add_listener(self.on_message_delete)
+
+ await self.update_page()
+ self.add_reactions()
+
+ def add_reactions(self) -> None:
+ """Adds the relevant reactions to the help message based on if pagination is required."""
+ # if paginating
+ if len(self._pages) > 1:
+ for reaction in REACTIONS:
+ self._bot.loop.create_task(self.message.add_reaction(reaction))
+
+ # if single-page
+ else:
+ self._bot.loop.create_task(self.message.add_reaction(DELETE_EMOJI))
+
+ def _category_key(self, cmd: Command) -> str:
+ """
+ Returns a cog name of a given command for use as a key for `sorted` and `groupby`.
+
+ A zero width space is used as a prefix for results with no cogs to force them last in ordering.
+ """
+ if cmd.cog:
+ try:
+ if cmd.cog.category:
+ return f'**{cmd.cog.category}**'
+ except AttributeError:
+ pass
+
+ return f'**{cmd.cog_name}**'
+ else:
+ return "**\u200bNo Category:**"
+
+ def _get_command_params(self, cmd: Command) -> str:
+ """
+ Returns the command usage signature.
+
+ This is a custom implementation of `command.signature` in order to format the command
+ signature without aliases.
+ """
+ results = []
+ for name, param in cmd.clean_params.items():
+
+ # if argument has a default value
+ if param.default is not param.empty:
+
+ if isinstance(param.default, str):
+ show_default = param.default
+ else:
+ show_default = param.default is not None
+
+ # if default is not an empty string or None
+ if show_default:
+ results.append(f'[{name}={param.default}]')
+ else:
+ results.append(f'[{name}]')
+
+ # if variable length argument
+ elif param.kind == param.VAR_POSITIONAL:
+ results.append(f'[{name}...]')
+
+ # if required
+ else:
+ results.append(f'<{name}>')
+
+ return f"{cmd.name} {' '.join(results)}"
+
+ async def build_pages(self) -> None:
+ """Builds the list of content pages to be paginated through in the help message, as a list of str."""
+ # Use LinePaginator to restrict embed line height
+ paginator = LinePaginator(prefix='', suffix='', max_lines=self._max_lines)
+
+ prefix = constants.Client.prefix
+
+ # show signature if query is a command
+ if isinstance(self.query, commands.Command):
+ signature = self._get_command_params(self.query)
+ parent = self.query.full_parent_name + ' ' if self.query.parent else ''
+ paginator.add_line(f'**```{prefix}{parent}{signature}```**')
+
+ aliases = ', '.join(f'`{a}`' for a in self.query.aliases)
+ if aliases:
+ paginator.add_line(f'**Can also use:** {aliases}\n')
+
+ if not await self.query.can_run(self._ctx):
+ paginator.add_line('***You cannot run this command.***\n')
+
+ if isinstance(self.query, Cog):
+ paginator.add_line(f'**{self.query.name}**')
+
+ if self.description:
+ paginator.add_line(f'*{self.description}*')
+
+ # list all children commands of the queried object
+ if isinstance(self.query, (commands.GroupMixin, Cog)):
+
+ # remove hidden commands if session is not wanting hiddens
+ if not self._show_hidden:
+ filtered = [c for c in self.query.commands if not c.hidden]
+ else:
+ filtered = self.query.commands
+
+ # if after filter there are no commands, finish up
+ if not filtered:
+ self._pages = paginator.pages
+ return
+
+ if isinstance(self.query, Cog):
+ grouped = (('**Commands:**', self.query.commands),)
+
+ elif isinstance(self.query, commands.Command):
+ grouped = (('**Subcommands:**', self.query.commands),)
+
+ # don't show prefix for subcommands
+ prefix = ''
+
+ # otherwise sort and organise all commands into categories
+ else:
+ cat_sort = sorted(filtered, key=self._category_key)
+ grouped = itertools.groupby(cat_sort, key=self._category_key)
+
+ for category, cmds in grouped:
+ cmds = sorted(cmds, key=lambda c: c.name)
+
+ if len(cmds) == 0:
+ continue
+
+ cat_cmds = []
+
+ for command in cmds:
+
+ # skip if hidden and hide if session is set to
+ if command.hidden and not self._show_hidden:
+ continue
+
+ # see if the user can run the command
+ strikeout = ''
+
+ # Patch to make the !help command work outside of #bot-commands again
+ # This probably needs a proper rewrite, but this will make it work in
+ # the mean time.
+ try:
+ can_run = await command.can_run(self._ctx)
+ except CheckFailure:
+ can_run = False
+
+ if not can_run:
+ # skip if we don't show commands they can't run
+ if self._only_can_run:
+ continue
+ strikeout = '~~'
+
+ signature = self._get_command_params(command)
+ info = f"{strikeout}**`{prefix}{signature}`**{strikeout}"
+
+ # handle if the command has no docstring
+ if command.short_doc:
+ cat_cmds.append(f'{info}\n*{command.short_doc}*')
+ else:
+ cat_cmds.append(f'{info}\n*No details provided.*')
+
+ # state var for if the category should be added next
+ print_cat = 1
+ new_page = True
+
+ for details in cat_cmds:
+
+ # keep details together, paginating early if it won't fit
+ lines_adding = len(details.split('\n')) + print_cat
+ if paginator._linecount + lines_adding > self._max_lines:
+ paginator._linecount = 0
+ new_page = True
+ paginator.close_page()
+
+ # new page so print category title again
+ print_cat = 1
+
+ if print_cat:
+ if new_page:
+ paginator.add_line('')
+ paginator.add_line(category)
+ print_cat = 0
+
+ paginator.add_line(details)
+
+ self._pages = paginator.pages
+
+ def embed_page(self, page_number: int = 0) -> Embed:
+ """Returns an Embed with the requested page formatted within."""
+ embed = Embed()
+
+ if isinstance(self.query, (commands.Command, Cog)) and page_number > 0:
+ title = f'Command Help | "{self.query.name}"'
+ else:
+ title = self.title
+
+ embed.set_author(name=title, icon_url=constants.Icons.questionmark)
+ embed.description = self._pages[page_number]
+
+ page_count = len(self._pages)
+ if page_count > 1:
+ embed.set_footer(text=f'Page {self._current_page+1} / {page_count}')
+
+ return embed
+
+ async def update_page(self, page_number: int = 0) -> None:
+ """Sends the intial message, or changes the existing one to the given page number."""
+ self._current_page = page_number
+ embed_page = self.embed_page(page_number)
+
+ if not self.message:
+ self.message = await self.destination.send(embed=embed_page)
+ else:
+ await self.message.edit(embed=embed_page)
+
+ @classmethod
+ async def start(cls, ctx: Context, *command, **options) -> "HelpSession":
+ """
+ Create and begin a help session based on the given command context.
+
+ Available options kwargs:
+ * cleanup: Optional[bool]
+ Set to `True` to have the message deleted on session end. Defaults to `False`.
+ * only_can_run: Optional[bool]
+ Set to `True` to hide commands the user can't run. Defaults to `False`.
+ * show_hidden: Optional[bool]
+ Set to `True` to include hidden commands. Defaults to `False`.
+ * max_lines: Optional[int]
+ Sets the max number of lines the paginator will add to a single page. Defaults to 20.
+ """
+ session = cls(ctx, *command, **options)
+ await session.prepare()
+
+ return session
+
+ async def stop(self) -> None:
+ """Stops the help session, removes event listeners and attempts to delete the help message."""
+ self._bot.remove_listener(self.on_reaction_add)
+ self._bot.remove_listener(self.on_message_delete)
+
+ # ignore if permission issue, or the message doesn't exist
+ with suppress(HTTPException, AttributeError):
+ if self._cleanup:
+ await self.message.delete()
+ else:
+ await self.message.clear_reactions()
+
+ @property
+ def is_first_page(self) -> bool:
+ """Check if session is currently showing the first page."""
+ return self._current_page == 0
+
+ @property
+ def is_last_page(self) -> bool:
+ """Check if the session is currently showing the last page."""
+ return self._current_page == (len(self._pages)-1)
+
+ async def do_first(self) -> None:
+ """Event that is called when the user requests the first page."""
+ if not self.is_first_page:
+ await self.update_page(0)
+
+ async def do_back(self) -> None:
+ """Event that is called when the user requests the previous page."""
+ if not self.is_first_page:
+ await self.update_page(self._current_page-1)
+
+ async def do_next(self) -> None:
+ """Event that is called when the user requests the next page."""
+ if not self.is_last_page:
+ await self.update_page(self._current_page+1)
+
+ async def do_end(self) -> None:
+ """Event that is called when the user requests the last page."""
+ if not self.is_last_page:
+ await self.update_page(len(self._pages)-1)
+
+ async def do_stop(self) -> None:
+ """Event that is called when the user requests to stop the help session."""
+ await self.message.delete()
+
+
+class Help(DiscordCog):
+ """Custom Embed Pagination Help feature."""
+
+ @commands.command('help')
+ async def new_help(self, ctx: Context, *commands) -> None:
+ """Shows Command Help."""
+ try:
+ await HelpSession.start(ctx, *commands)
+ except HelpQueryNotFound as error:
+ embed = Embed()
+ embed.colour = Colour.red()
+ embed.title = str(error)
+
+ if error.possible_matches:
+ matches = '\n'.join(error.possible_matches.keys())
+ embed.description = f'**Did you mean:**\n`{matches}`'
+
+ await ctx.send(embed=embed)
+
+
+def unload(bot: SeasonalBot) -> None:
+ """
+ Reinstates the original help command.
+
+ This is run if the cog raises an exception on load, or if the extension is unloaded.
+ """
+ bot.remove_command('help')
+ bot.add_command(bot._old_help)
+
+
+def setup(bot: SeasonalBot) -> None:
+ """
+ The setup for the help extension.
+
+ This is called automatically on `bot.load_extension` being run.
+ Stores the original help command instance on the `bot._old_help` attribute for later
+ reinstatement, before removing it from the command registry so the new help command can be
+ loaded successfully.
+ If an exception is raised during the loading of the cog, `unload` will be called in order to
+ reinstate the original help command.
+ """
+ bot._old_help = bot.get_command('help')
+ bot.remove_command('help')
+
+ try:
+ bot.add_cog(Help())
+ except Exception:
+ unload(bot)
+ raise
+
+
+def teardown(bot: SeasonalBot) -> None:
+ """
+ The teardown for the help extension.
+
+ This is called automatically on `bot.unload_extension` being run.
+ Calls `unload` in order to reinstate the original help command.
+ """
+ unload(bot)
diff --git a/bot/exts/evergreen/issues.py b/bot/exts/evergreen/issues.py
new file mode 100644
index 00000000..4129156a
--- /dev/null
+++ b/bot/exts/evergreen/issues.py
@@ -0,0 +1,76 @@
+import logging
+
+import discord
+from discord.ext import commands
+
+from bot.constants import Colours, Emojis, WHITELISTED_CHANNELS
+from bot.utils.decorators import override_in_channel
+
+log = logging.getLogger(__name__)
+
+BAD_RESPONSE = {
+ 404: "Issue/pull request not located! Please enter a valid number!",
+ 403: "Rate limit has been hit! Please try again later!"
+}
+
+
+class Issues(commands.Cog):
+ """Cog that allows users to retrieve issues from GitHub."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @commands.command(aliases=("pr",))
+ @override_in_channel(WHITELISTED_CHANNELS)
+ async def issue(
+ self, ctx: commands.Context, number: int, repository: str = "seasonalbot", user: str = "python-discord"
+ ) -> None:
+ """Command to retrieve issues from a GitHub repository."""
+ url = f"https://api.github.com/repos/{user}/{repository}/issues/{number}"
+ merge_url = f"https://api.github.com/repos/{user}/{repository}/pulls/{number}/merge"
+
+ log.trace(f"Querying GH issues API: {url}")
+ async with self.bot.http_session.get(url) as r:
+ json_data = await r.json()
+
+ if r.status in BAD_RESPONSE:
+ log.warning(f"Received response {r.status} from: {url}")
+ return await ctx.send(f"[{str(r.status)}] {BAD_RESPONSE.get(r.status)}")
+
+ # The initial API request is made to the issues API endpoint, which will return information
+ # if the issue or PR is present. However, the scope of information returned for PRs differs
+ # from issues: if the 'issues' key is present in the response then we can pull the data we
+ # need from the initial API call.
+ if "issues" in json_data.get("html_url"):
+ if json_data.get("state") == "open":
+ icon_url = Emojis.issue
+ else:
+ icon_url = Emojis.issue_closed
+
+ # If the 'issues' key is not contained in the API response and there is no error code, then
+ # we know that a PR has been requested and a call to the pulls API endpoint is necessary
+ # to get the desired information for the PR.
+ else:
+ log.trace(f"PR provided, querying GH pulls API for additional information: {merge_url}")
+ async with self.bot.http_session.get(merge_url) as m:
+ if json_data.get("state") == "open":
+ icon_url = Emojis.pull_request
+ # When the status is 204 this means that the state of the PR is merged
+ elif m.status == 204:
+ icon_url = Emojis.merge
+ else:
+ icon_url = Emojis.pull_request_closed
+
+ issue_url = json_data.get("html_url")
+ description_text = f"[{repository}] #{number} {json_data.get('title')}"
+ resp = discord.Embed(
+ colour=Colours.bright_green,
+ description=f"{icon_url} [{description_text}]({issue_url})"
+ )
+ resp.set_author(name="GitHub", url=issue_url)
+ await ctx.send(embed=resp)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Cog Retrieves Issues From Github."""
+ bot.add_cog(Issues(bot))
diff --git a/bot/exts/evergreen/magic_8ball.py b/bot/exts/evergreen/magic_8ball.py
new file mode 100644
index 00000000..c10f1f51
--- /dev/null
+++ b/bot/exts/evergreen/magic_8ball.py
@@ -0,0 +1,31 @@
+import json
+import logging
+import random
+from pathlib import Path
+
+from discord.ext import commands
+
+log = logging.getLogger(__name__)
+
+
+class Magic8ball(commands.Cog):
+ """A Magic 8ball command to respond to a user's question."""
+
+ def __init__(self, bot: commands.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: commands.Context, *, question: str) -> None:
+ """Return a Magic 8ball 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?`)")
+
+
+def setup(bot: commands.Bot) -> None:
+ """Magic 8ball Cog load."""
+ bot.add_cog(Magic8ball(bot))
diff --git a/bot/exts/evergreen/minesweeper.py b/bot/exts/evergreen/minesweeper.py
new file mode 100644
index 00000000..b59cdb14
--- /dev/null
+++ b/bot/exts/evergreen/minesweeper.py
@@ -0,0 +1,284 @@
+import logging
+import typing
+from dataclasses import dataclass
+from random import randint, random
+
+import discord
+from discord.ext import commands
+
+from bot.constants import Client
+
+MESSAGE_MAPPING = {
+ 0: ":stop_button:",
+ 1: ":one:",
+ 2: ":two:",
+ 3: ":three:",
+ 4: ":four:",
+ 5: ":five:",
+ 6: ":six:",
+ 7: ":seven:",
+ 8: ":eight:",
+ 9: ":nine:",
+ 10: ":keycap_ten:",
+ "bomb": ":bomb:",
+ "hidden": ":grey_question:",
+ "flag": ":flag_black:",
+ "x": ":x:"
+}
+
+log = logging.getLogger(__name__)
+
+
+class CoordinateConverter(commands.Converter):
+ """Converter for Coordinates."""
+
+ async def convert(self, ctx: commands.Context, coordinate: str) -> typing.Tuple[int, int]:
+ """Take in a coordinate string and turn it into an (x, y) tuple."""
+ if not 2 <= len(coordinate) <= 3:
+ raise commands.BadArgument('Invalid co-ordinate provided')
+
+ coordinate = coordinate.lower()
+ if coordinate[0].isalpha():
+ digit = coordinate[1:]
+ letter = coordinate[0]
+ else:
+ digit = coordinate[:-1]
+ letter = coordinate[-1]
+
+ if not digit.isdigit():
+ raise commands.BadArgument
+
+ x = ord(letter) - ord('a')
+ y = int(digit) - 1
+
+ if (not 0 <= x <= 9) or (not 0 <= y <= 9):
+ raise commands.BadArgument
+ return x, y
+
+
+GameBoard = typing.List[typing.List[typing.Union[str, int]]]
+
+
+@dataclass
+class Game:
+ """The data for a game."""
+
+ board: GameBoard
+ revealed: GameBoard
+ dm_msg: discord.Message
+ chat_msg: discord.Message
+ activated_on_server: bool
+
+
+GamesDict = typing.Dict[int, Game]
+
+
+class Minesweeper(commands.Cog):
+ """Play a game of Minesweeper."""
+
+ def __init__(self, bot: commands.Bot) -> None:
+ self.games: GamesDict = {} # Store the currently running games
+
+ @commands.group(name='minesweeper', aliases=('ms',), invoke_without_command=True)
+ async def minesweeper_group(self, ctx: commands.Context) -> None:
+ """Commands for Playing Minesweeper."""
+ await ctx.send_help(ctx.command)
+
+ @staticmethod
+ def get_neighbours(x: int, y: int) -> typing.Generator[typing.Tuple[int, int], None, None]:
+ """Get all the neighbouring x and y including it self."""
+ for x_ in [x - 1, x, x + 1]:
+ for y_ in [y - 1, y, y + 1]:
+ if x_ != -1 and x_ != 10 and y_ != -1 and y_ != 10:
+ yield x_, y_
+
+ def generate_board(self, bomb_chance: float) -> GameBoard:
+ """Generate a 2d array for the board."""
+ board: GameBoard = [
+ [
+ "bomb" if random() <= bomb_chance else "number"
+ for _ in range(10)
+ ] for _ in range(10)
+ ]
+
+ # make sure there is always a free cell
+ board[randint(0, 9)][randint(0, 9)] = "number"
+
+ for y, row in enumerate(board):
+ for x, cell in enumerate(row):
+ if cell == "number":
+ # calculate bombs near it
+ bombs = 0
+ for x_, y_ in self.get_neighbours(x, y):
+ if board[y_][x_] == "bomb":
+ bombs += 1
+ board[y][x] = bombs
+ return board
+
+ @staticmethod
+ def format_for_discord(board: GameBoard) -> str:
+ """Format the board as a string for Discord."""
+ discord_msg = (
+ ":stop_button: :regional_indicator_a::regional_indicator_b::regional_indicator_c:"
+ ":regional_indicator_d::regional_indicator_e::regional_indicator_f::regional_indicator_g:"
+ ":regional_indicator_h::regional_indicator_i::regional_indicator_j:\n\n"
+ )
+ rows = []
+ for row_number, row in enumerate(board):
+ new_row = f"{MESSAGE_MAPPING[row_number + 1]} "
+ new_row += "".join(MESSAGE_MAPPING[cell] for cell in row)
+ rows.append(new_row)
+
+ discord_msg += "\n".join(rows)
+ return discord_msg
+
+ @minesweeper_group.command(name="start")
+ async def start_command(self, ctx: commands.Context, bomb_chance: float = .2) -> None:
+ """Start a game of Minesweeper."""
+ if ctx.author.id in self.games: # Player is already playing
+ await ctx.send(f"{ctx.author.mention} you already have a game running!", delete_after=2)
+ await ctx.message.delete(delay=2)
+ return
+
+ # Add game to list
+ board: GameBoard = self.generate_board(bomb_chance)
+ revealed_board: GameBoard = [["hidden"] * 10 for _ in range(10)]
+
+ if ctx.guild:
+ await ctx.send(f"{ctx.author.mention} is playing Minesweeper")
+ chat_msg = await ctx.send(f"Here's there board!\n{self.format_for_discord(revealed_board)}")
+ else:
+ chat_msg = None
+
+ await ctx.author.send(
+ f"Play by typing: `{Client.prefix}ms reveal xy [xy]` or `{Client.prefix}ms flag xy [xy]` \n"
+ f"Close the game with `{Client.prefix}ms end`\n"
+ )
+ dm_msg = await ctx.author.send(f"Here's your board!\n{self.format_for_discord(revealed_board)}")
+
+ self.games[ctx.author.id] = Game(
+ board=board,
+ revealed=revealed_board,
+ dm_msg=dm_msg,
+ chat_msg=chat_msg,
+ activated_on_server=ctx.guild is not None
+ )
+
+ async def update_boards(self, ctx: commands.Context) -> None:
+ """Update both playing boards."""
+ game = self.games[ctx.author.id]
+ await game.dm_msg.delete()
+ game.dm_msg = await ctx.author.send(f"Here's your board!\n{self.format_for_discord(game.revealed)}")
+ if game.activated_on_server:
+ await game.chat_msg.edit(content=f"Here's there board!\n{self.format_for_discord(game.revealed)}")
+
+ @commands.dm_only()
+ @minesweeper_group.command(name="flag")
+ async def flag_command(self, ctx: commands.Context, *coordinates: CoordinateConverter) -> None:
+ """Place multiple flags on the board."""
+ board: GameBoard = self.games[ctx.author.id].revealed
+ for x, y in coordinates:
+ if board[y][x] == "hidden":
+ board[y][x] = "flag"
+
+ await self.update_boards(ctx)
+
+ @staticmethod
+ def reveal_bombs(revealed: GameBoard, board: GameBoard) -> None:
+ """Reveals all the bombs."""
+ for y, row in enumerate(board):
+ for x, cell in enumerate(row):
+ if cell == "bomb":
+ revealed[y][x] = cell
+
+ async def lost(self, ctx: commands.Context) -> None:
+ """The player lost the game."""
+ game = self.games[ctx.author.id]
+ self.reveal_bombs(game.revealed, game.board)
+ await ctx.author.send(":fire: You lost! :fire:")
+ if game.activated_on_server:
+ await game.chat_msg.channel.send(f":fire: {ctx.author.mention} just lost Minesweeper! :fire:")
+
+ async def won(self, ctx: commands.Context) -> None:
+ """The player won the game."""
+ game = self.games[ctx.author.id]
+ await ctx.author.send(":tada: You won! :tada:")
+ if game.activated_on_server:
+ await game.chat_msg.channel.send(f":tada: {ctx.author.mention} just won Minesweeper! :tada:")
+
+ def reveal_zeros(self, revealed: GameBoard, board: GameBoard, x: int, y: int) -> None:
+ """Recursively reveal adjacent cells when a 0 cell is encountered."""
+ for x_, y_ in self.get_neighbours(x, y):
+ if revealed[y_][x_] != "hidden":
+ continue
+ revealed[y_][x_] = board[y_][x_]
+ if board[y_][x_] == 0:
+ self.reveal_zeros(revealed, board, x_, y_)
+
+ async def check_if_won(self, ctx: commands.Context, revealed: GameBoard, board: GameBoard) -> bool:
+ """Checks if a player has won."""
+ if any(
+ revealed[y][x] in ["hidden", "flag"] and board[y][x] != "bomb"
+ for x in range(10)
+ for y in range(10)
+ ):
+ return False
+ else:
+ await self.won(ctx)
+ return True
+
+ async def reveal_one(
+ self,
+ ctx: commands.Context,
+ revealed: GameBoard,
+ board: GameBoard,
+ x: int,
+ y: int
+ ) -> bool:
+ """
+ Reveal one square.
+
+ return is True if the game ended, breaking the loop in `reveal_command` and deleting the game
+ """
+ revealed[y][x] = board[y][x]
+ if board[y][x] == "bomb":
+ await self.lost(ctx)
+ revealed[y][x] = "x" # mark bomb that made you lose with a x
+ return True
+ elif board[y][x] == 0:
+ self.reveal_zeros(revealed, board, x, y)
+ return await self.check_if_won(ctx, revealed, board)
+
+ @commands.dm_only()
+ @minesweeper_group.command(name="reveal")
+ async def reveal_command(self, ctx: commands.Context, *coordinates: CoordinateConverter) -> None:
+ """Reveal multiple cells."""
+ game = self.games[ctx.author.id]
+ revealed: GameBoard = game.revealed
+ board: GameBoard = game.board
+
+ for x, y in coordinates:
+ # reveal_one returns True if the revealed cell is a bomb or the player won, ending the game
+ if await self.reveal_one(ctx, revealed, board, x, y):
+ await self.update_boards(ctx)
+ del self.games[ctx.author.id]
+ break
+ else:
+ await self.update_boards(ctx)
+
+ @minesweeper_group.command(name="end")
+ async def end_command(self, ctx: commands.Context) -> None:
+ """End your current game."""
+ game = self.games[ctx.author.id]
+ game.revealed = game.board
+ await self.update_boards(ctx)
+ new_msg = f":no_entry: Game canceled :no_entry:\n{game.dm_msg.content}"
+ await game.dm_msg.edit(content=new_msg)
+ if game.activated_on_server:
+ await game.chat_msg.edit(content=new_msg)
+ del self.games[ctx.author.id]
+
+
+def setup(bot: commands.Bot) -> None:
+ """Load the Minesweeper cog."""
+ bot.add_cog(Minesweeper(bot))
diff --git a/bot/exts/evergreen/movie.py b/bot/exts/evergreen/movie.py
new file mode 100644
index 00000000..93aeef30
--- /dev/null
+++ b/bot/exts/evergreen/movie.py
@@ -0,0 +1,198 @@
+import logging
+import random
+from enum import Enum
+from typing import Any, Dict, List, Tuple
+from urllib.parse import urlencode
+
+from aiohttp import ClientSession
+from discord import Embed
+from discord.ext.commands import Bot, Cog, Context, group
+
+from bot.constants import Tokens
+from bot.utils.pagination import ImagePaginator
+
+# Define base URL of TMDB
+BASE_URL = "https://api.themoviedb.org/3/"
+
+logger = logging.getLogger(__name__)
+
+# Define movie params, that will be used for every movie request
+MOVIE_PARAMS = {
+ "api_key": Tokens.tmdb,
+ "language": "en-US"
+}
+
+
+class MovieGenres(Enum):
+ """Movies Genre names and IDs."""
+
+ Action = "28"
+ Adventure = "12"
+ Animation = "16"
+ Comedy = "35"
+ Crime = "80"
+ Documentary = "99"
+ Drama = "18"
+ Family = "10751"
+ Fantasy = "14"
+ History = "36"
+ Horror = "27"
+ Music = "10402"
+ Mystery = "9648"
+ Romance = "10749"
+ Science = "878"
+ Thriller = "53"
+ Western = "37"
+
+
+class Movie(Cog):
+ """Movie Cog contains movies command that grab random movies from TMDB."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self.http_session: ClientSession = bot.http_session
+
+ @group(name='movies', aliases=['movie'], invoke_without_command=True)
+ async def movies(self, ctx: Context, genre: str = "", amount: int = 5) -> None:
+ """
+ Get random movies by specifying genre. Also support amount parameter, that define how much movies will be shown.
+
+ Default 5. Use .movies genres to get all available genres.
+ """
+ # Check is there more than 20 movies specified, due TMDB return 20 movies
+ # per page, so this is max. Also you can't get less movies than 1, just logic
+ if amount > 20:
+ await ctx.send("You can't get more than 20 movies at once. (TMDB limits)")
+ return
+ elif amount < 1:
+ await ctx.send("You can't get less than 1 movie.")
+ return
+
+ # Capitalize genre for getting data from Enum, get random page, send help when genre don't exist.
+ genre = genre.capitalize()
+ try:
+ result = await self.get_movies_list(self.http_session, MovieGenres[genre].value, 1)
+ except KeyError:
+ await ctx.send_help('movies')
+ return
+
+ # Check if "results" is in result. If not, throw error.
+ if "results" not in result.keys():
+ err_msg = f"There is problem while making TMDB API request. Response Code: {result['status_code']}, " \
+ f"{result['status_message']}."
+ await ctx.send(err_msg)
+ logger.warning(err_msg)
+
+ # Get random page. Max page is last page where is movies with this genre.
+ page = random.randint(1, result["total_pages"])
+
+ # Get movies list from TMDB, check if results key in result. When not, raise error.
+ movies = await self.get_movies_list(self.http_session, MovieGenres[genre].value, page)
+ if 'results' not in movies.keys():
+ err_msg = f"There is problem while making TMDB API request. Response Code: {result['status_code']}, " \
+ f"{result['status_message']}."
+ await ctx.send(err_msg)
+ logger.warning(err_msg)
+
+ # Get all pages and embed
+ pages = await self.get_pages(self.http_session, movies, amount)
+ embed = await self.get_embed(genre)
+
+ await ImagePaginator.paginate(pages, ctx, embed)
+
+ @movies.command(name='genres', aliases=['genre', 'g'])
+ async def genres(self, ctx: Context) -> None:
+ """Show all currently available genres for .movies command."""
+ await ctx.send(f"Current available genres: {', '.join('`' + genre.name + '`' for genre in MovieGenres)}")
+
+ async def get_movies_list(self, client: ClientSession, genre_id: str, page: int) -> Dict[str, Any]:
+ """Return JSON of TMDB discover request."""
+ # Define params of request
+ params = {
+ "api_key": Tokens.tmdb,
+ "language": "en-US",
+ "sort_by": "popularity.desc",
+ "include_adult": "false",
+ "include_video": "false",
+ "page": page,
+ "with_genres": genre_id
+ }
+
+ url = BASE_URL + "discover/movie?" + urlencode(params)
+
+ # Make discover request to TMDB, return result
+ async with client.get(url) as resp:
+ return await resp.json()
+
+ async def get_pages(self, client: ClientSession, movies: Dict[str, Any], amount: int) -> List[Tuple[str, str]]:
+ """Fetch all movie pages from movies dictionary. Return list of pages."""
+ pages = []
+
+ for i in range(amount):
+ movie_id = movies['results'][i]['id']
+ movie = await self.get_movie(client, movie_id)
+
+ page, img = await self.create_page(movie)
+ pages.append((page, img))
+
+ return pages
+
+ async def get_movie(self, client: ClientSession, movie: int) -> Dict:
+ """Get Movie by movie ID from TMDB. Return result dictionary."""
+ url = BASE_URL + f"movie/{movie}?" + urlencode(MOVIE_PARAMS)
+
+ async with client.get(url) as resp:
+ return await resp.json()
+
+ async def create_page(self, movie: Dict[str, Any]) -> Tuple[str, str]:
+ """Create page from TMDB movie request result. Return formatted page + image."""
+ text = ""
+
+ # Add title + tagline (if not empty)
+ text += f"**{movie['title']}**\n"
+ if movie['tagline']:
+ text += f"{movie['tagline']}\n\n"
+ else:
+ text += "\n"
+
+ # Add other information
+ text += f"**Rating:** {movie['vote_average']}/10 :star:\n"
+ text += f"**Release Date:** {movie['release_date']}\n\n"
+
+ text += "__**Production Information**__\n"
+
+ companies = movie['production_companies']
+ countries = movie['production_countries']
+
+ text += f"**Made by:** {', '.join(company['name'] for company in companies)}\n"
+ text += f"**Made in:** {', '.join(country['name'] for country in countries)}\n\n"
+
+ text += "__**Some Numbers**__\n"
+
+ budget = f"{movie['budget']:,d}" if movie['budget'] else "?"
+ revenue = f"{movie['revenue']:,d}" if movie['revenue'] else "?"
+
+ if movie['runtime'] is not None:
+ duration = divmod(movie['runtime'], 60)
+ else:
+ duration = ("?", "?")
+
+ text += f"**Budget:** ${budget}\n"
+ text += f"**Revenue:** ${revenue}\n"
+ text += f"**Duration:** {f'{duration[0]} hour(s) {duration[1]} minute(s)'}\n\n"
+
+ text += movie['overview']
+
+ img = f"http://image.tmdb.org/t/p/w200{movie['poster_path']}"
+
+ # Return page content and image
+ return text, img
+
+ async def get_embed(self, name: str) -> Embed:
+ """Return embed of random movies. Uses name in title."""
+ return Embed(title=f'Random {name} Movies').set_footer(text='Powered by TMDB (themoviedb.org)')
+
+
+def setup(bot: Bot) -> None:
+ """Load Movie Cog."""
+ bot.add_cog(Movie(bot))
diff --git a/bot/exts/evergreen/recommend_game.py b/bot/exts/evergreen/recommend_game.py
new file mode 100644
index 00000000..7cd52c2c
--- /dev/null
+++ b/bot/exts/evergreen/recommend_game.py
@@ -0,0 +1,50 @@
+import json
+import logging
+from pathlib import Path
+from random import shuffle
+
+import discord
+from discord.ext import commands
+
+log = logging.getLogger(__name__)
+game_recs = []
+
+# Populate the list `game_recs` with resource files
+for rec_path in Path("bot/resources/evergreen/game_recs").glob("*.json"):
+ with rec_path.open(encoding='utf-8') as file:
+ data = json.load(file)
+ game_recs.append(data)
+shuffle(game_recs)
+
+
+class RecommendGame(commands.Cog):
+ """Commands related to recommending games."""
+
+ def __init__(self, bot: commands.Bot) -> None:
+ self.bot = bot
+ self.index = 0
+
+ @commands.command(name="recommendgame", aliases=['gamerec'])
+ async def recommend_game(self, ctx: commands.Context) -> None:
+ """Sends an Embed of a random game recommendation."""
+ if self.index >= len(game_recs):
+ self.index = 0
+ shuffle(game_recs)
+ game = game_recs[self.index]
+ self.index += 1
+
+ author = self.bot.get_user(int(game['author']))
+
+ # Creating and formatting Embed
+ embed = discord.Embed(color=discord.Colour.blue())
+ if author is not None:
+ embed.set_author(name=author.name, icon_url=author.avatar_url)
+ embed.set_image(url=game['image'])
+ embed.add_field(name='Recommendation: ' + game['title'] + '\n' + game['link'], value=game['description'])
+
+ await ctx.send(embed=embed)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Loads the RecommendGame cog."""
+ bot.add_cog(RecommendGame(bot))
diff --git a/bot/exts/evergreen/reddit.py b/bot/exts/evergreen/reddit.py
new file mode 100644
index 00000000..fe204419
--- /dev/null
+++ b/bot/exts/evergreen/reddit.py
@@ -0,0 +1,128 @@
+import logging
+import random
+
+import discord
+from discord.ext import commands
+from discord.ext.commands.cooldowns import BucketType
+
+from bot.utils.pagination import ImagePaginator
+
+log = logging.getLogger(__name__)
+
+
+class Reddit(commands.Cog):
+ """Fetches reddit posts."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ async def fetch(self, url: str) -> dict:
+ """Send a get request to the reddit API and get json response."""
+ session = self.bot.http_session
+ params = {
+ 'limit': 50
+ }
+ headers = {
+ 'User-Agent': 'Iceman'
+ }
+
+ async with session.get(url=url, params=params, headers=headers) as response:
+ return await response.json()
+
+ @commands.command(name='reddit')
+ @commands.cooldown(1, 10, BucketType.user)
+ async def get_reddit(self, ctx: commands.Context, subreddit: str = 'python', sort: str = "hot") -> None:
+ """
+ Fetch reddit posts by using this command.
+
+ Gets a post from r/python by default.
+ Usage:
+ --> .reddit [subreddit_name] [hot/top/new]
+ """
+ pages = []
+ sort_list = ["hot", "new", "top", "rising"]
+ if sort.lower() not in sort_list:
+ await ctx.send(f"Invalid sorting: {sort}\nUsing default sorting: `Hot`")
+ sort = "hot"
+
+ data = await self.fetch(f'https://www.reddit.com/r/{subreddit}/{sort}/.json')
+
+ try:
+ posts = data["data"]["children"]
+ except KeyError:
+ return await ctx.send('Subreddit not found!')
+ if not posts:
+ return await ctx.send('No posts available!')
+
+ if posts[1]["data"]["over_18"] is True:
+ return await ctx.send(
+ "You cannot access this Subreddit as it is ment for those who "
+ "are 18 years or older."
+ )
+
+ embed_titles = ""
+
+ # Chooses k unique random elements from a population sequence or set.
+ random_posts = random.sample(posts, k=5)
+
+ # -----------------------------------------------------------
+ # This code below is bound of change when the emojis are added.
+
+ upvote_emoji = self.bot.get_emoji(638729835245731840)
+ comment_emoji = self.bot.get_emoji(638729835073765387)
+ user_emoji = self.bot.get_emoji(638729835442602003)
+ text_emoji = self.bot.get_emoji(676030265910493204)
+ video_emoji = self.bot.get_emoji(676030265839190047)
+ image_emoji = self.bot.get_emoji(676030265734201344)
+ reddit_emoji = self.bot.get_emoji(676030265734332427)
+
+ # ------------------------------------------------------------
+
+ for i, post in enumerate(random_posts, start=1):
+ post_title = post["data"]["title"][0:50]
+ post_url = post['data']['url']
+ if post_title == "":
+ post_title = "No Title."
+ elif post_title == post_url:
+ post_title = "Title is itself a link."
+
+ # ------------------------------------------------------------------
+ # Embed building.
+
+ embed_titles += f"**{i}.[{post_title}]({post_url})**\n"
+ image_url = " "
+ post_stats = f"{text_emoji}" # Set default content type to text.
+
+ if post["data"]["is_video"] is True or "youtube" in post_url.split("."):
+ # This means the content type in the post is a video.
+ post_stats = f"{video_emoji} "
+
+ elif post_url.endswith("jpg") or post_url.endswith("png") or post_url.endswith("gif"):
+ # This means the content type in the post is an image.
+ post_stats = f"{image_emoji} "
+ image_url = post_url
+
+ votes = f'{upvote_emoji}{post["data"]["ups"]}'
+ comments = f'{comment_emoji}\u2002{ post["data"]["num_comments"]}'
+ post_stats += (
+ f"\u2002{votes}\u2003"
+ f"{comments}"
+ f'\u2003{user_emoji}\u2002{post["data"]["author"]}\n'
+ )
+ embed_titles += f"{post_stats}\n"
+ page_text = f"**[{post_title}]({post_url})**\n{post_stats}\n{post['data']['selftext'][0:200]}"
+
+ embed = discord.Embed()
+ page_tuple = (page_text, image_url)
+ pages.append(page_tuple)
+
+ # ------------------------------------------------------------------
+
+ pages.insert(0, (embed_titles, " "))
+ embed.set_author(name=f"r/{posts[0]['data']['subreddit']} - {sort}", icon_url=reddit_emoji.url)
+ await ImagePaginator.paginate(pages, ctx, embed)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Load the Cog."""
+ bot.add_cog(Reddit(bot))
diff --git a/bot/exts/evergreen/showprojects.py b/bot/exts/evergreen/showprojects.py
new file mode 100644
index 00000000..328a7aa5
--- /dev/null
+++ b/bot/exts/evergreen/showprojects.py
@@ -0,0 +1,33 @@
+import logging
+
+from discord import Message
+from discord.ext import commands
+
+from bot.constants import Channels
+
+log = logging.getLogger(__name__)
+
+
+class ShowProjects(commands.Cog):
+ """Cog that reacts to posts in the #show-your-projects."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+ self.lastPoster = 0 # Given 0 as the default last poster ID as no user can actually have 0 assigned to them
+
+ @commands.Cog.listener()
+ async def on_message(self, message: Message) -> None:
+ """Adds reactions to posts in #show-your-projects."""
+ reactions = ["\U0001f44d", "\U00002764", "\U0001f440", "\U0001f389", "\U0001f680", "\U00002b50", "\U0001f6a9"]
+ if (message.channel.id == Channels.show_your_projects
+ and message.author.bot is False
+ and message.author.id != self.lastPoster):
+ for reaction in reactions:
+ await message.add_reaction(reaction)
+
+ self.lastPoster = message.author.id
+
+
+def setup(bot: commands.Bot) -> None:
+ """Show Projects Reaction Cog."""
+ bot.add_cog(ShowProjects(bot))
diff --git a/bot/exts/evergreen/snakes/__init__.py b/bot/exts/evergreen/snakes/__init__.py
new file mode 100644
index 00000000..2eae2751
--- /dev/null
+++ b/bot/exts/evergreen/snakes/__init__.py
@@ -0,0 +1,12 @@
+import logging
+
+from discord.ext import commands
+
+from bot.exts.evergreen.snakes.snakes_cog import Snakes
+
+log = logging.getLogger(__name__)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Snakes Cog load."""
+ bot.add_cog(Snakes(bot))
diff --git a/bot/exts/evergreen/snakes/converter.py b/bot/exts/evergreen/snakes/converter.py
new file mode 100644
index 00000000..d4e93b56
--- /dev/null
+++ b/bot/exts/evergreen/snakes/converter.py
@@ -0,0 +1,85 @@
+import json
+import logging
+import random
+from typing import Iterable, List
+
+import discord
+from discord.ext.commands import Context, Converter
+from fuzzywuzzy import fuzz
+
+from bot.exts.evergreen.snakes.utils import SNAKE_RESOURCES
+from bot.utils import disambiguate
+
+log = logging.getLogger(__name__)
+
+
+class Snake(Converter):
+ """Snake converter for the Snakes Cog."""
+
+ snakes = None
+ special_cases = None
+
+ async def convert(self, ctx: Context, name: str) -> str:
+ """Convert the input snake name to the closest matching Snake object."""
+ await self.build_list()
+ name = name.lower()
+
+ if name == 'python':
+ return 'Python (programming language)'
+
+ def get_potential(iterable: Iterable, *, threshold: int = 80) -> List[str]:
+ 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) -> None:
+ """Build list of snakes from the static snake resources."""
+ # 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) -> str:
+ """
+ Get a random Snake from the loaded resources.
+
+ 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.
+ """
+ await cls.build_list()
+ names = [snake['scientific'] for snake in cls.snakes]
+ return random.choice(names)
diff --git a/bot/exts/evergreen/snakes/snakes_cog.py b/bot/exts/evergreen/snakes/snakes_cog.py
new file mode 100644
index 00000000..36c176ce
--- /dev/null
+++ b/bot/exts/evergreen/snakes/snakes_cog.py
@@ -0,0 +1,1149 @@
+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, List
+
+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, CommandError, Context, bot_has_permissions, group
+
+from bot.constants import ERROR_REPLIES, Tokens
+from bot.exts.evergreen.snakes import utils
+from bot.exts.evergreen.snakes.converter import Snake
+from bot.utils.decorators import locked
+
+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, created by our community during the first code jam.
+
+ More information can be found in the code-jam-1 repo.
+
+ https://github.com/python-discord/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: float) -> int:
+ """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: str) -> str:
+ """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: aiohttp.ClientSession, url: str, params: dict = None) -> dict:
+ """Asynchronous 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: List[str], retries: int = 10) -> str:
+ """
+ Fetch a message that's at least 3 words long, if 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]:
+ """
+ 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.
+ """
+ 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 random.choice(self.snake_names)
+
+ async def _validate_answer(self, ctx: Context, message: Message, answer: str, options: list) -> None:
+ """Validate the answer using a reaction event loop."""
+ def predicate(reaction: Reaction, user: Member) -> bool:
+ """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) -> None:
+ """Commands from our first code jam."""
+ await ctx.send_help(ctx.command)
+
+ @bot_has_permissions(manage_messages=True)
+ @snakes_group.command(name='antidote')
+ @locked()
+ async def antidote_command(self, ctx: Context) -> None:
+ """
+ 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) -> bool:
+ """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) -> None:
+ """
+ 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) -> None:
+ """
+ Fetches information about a snake from Wikipedia.
+
+ 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: Context) -> None:
+ """
+ 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) -> None:
+ """
+ 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) -> None:
+ """
+ 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) -> None:
+ """
+ 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) -> None:
+ """
+ Snakifies a username.
+
+ 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) -> None:
+ """
+ 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) -> None:
+ """Show an embed with information about the event, its 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) -> 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) -> None:
+ """
+ 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='snakify')
+ async def snakify_command(self, ctx: Context, *, message: str = None) -> None:
+ """
+ How would I talk if I were a snake?
+
+ If `message` is passed, the bot will snakify the message.
+ Otherwise, a random message from the user's history is snakified.
+
+ 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) -> None:
+ """
+ Gets a YouTube video about snakes.
+
+ If `search` is given, a snake with that name will be searched on Youtube.
+
+ 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) -> None:
+ """
+ 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: Context, error: CommandError) -> None:
+ """Local error handler for the Snake Cog."""
+ 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/exts/evergreen/snakes/utils.py b/bot/exts/evergreen/snakes/utils.py
new file mode 100644
index 00000000..7d6caf04
--- /dev/null
+++ b/bot/exts/evergreen/snakes/utils.py
@@ -0,0 +1,716 @@
+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
+
+from PIL import Image
+from PIL.ImageDraw import ImageDraw
+from discord import File, Member, Reaction
+from discord.ext.commands import Cog, Context
+
+from bot.constants import Roles
+
+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]:
+ """Load Snake resources JSON."""
+ with (SNAKE_RESOURCES / f"{file}.json").open(encoding="utf-8") as snakefile:
+ return json.load(snakefile)
+
+
+def smoothstep(t: float) -> float:
+ """Smooth curve with a zero derivative at 0 and 1, making it useful for interpolating."""
+ return t * t * (3. - 2. * t)
+
+
+def lerp(t: float, a: float, b: float) -> float:
+ """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.
+
+ Taken from: https://gist.github.com/eevee/26f547457522755cb1fb8739d0ea89a1
+ Licensed under ISC
+ """
+
+ def __init__(self, dimension: int, octaves: int = 1, tile: Tuple[int] = (), unbias: bool = False):
+ """
+ Create a new Perlin noise factory in the given number of dimensions.
+
+ dimension 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) -> Tuple[float, ...]:
+ """
+ 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) -> float:
+ """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) -> float:
+ """
+ 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.
+
+ `perlin_lookup_vertical_shift` represents the Perlin noise shift in the Y-dimension for this frame.
+ If `text` is given, display the given text with the snek.
+ """
+ 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) -> io.BytesIO:
+ """Convert image to byte stream."""
+ stream = io.BytesIO()
+ image.save(stream, format='PNG')
+ stream.seek(0)
+ return stream
+
+
+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:
+ """Snakes and Ladders game Cog."""
+
+ def __init__(self, snakes: Cog, 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) -> None:
+ """
+ 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) -> bool:
+ """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"),
+ 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 user == self.author or (self._is_moderator(user) and user not in self.players):
+ # Allow game author or non-playing moderation staff to cancel a waiting game
+ await self.cancel_game()
+ 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")
+ await self.cancel_game()
+ return # We're done, no reactions for the last 5 minutes
+
+ async def _add_player(self, user: Member) -> None:
+ """Add player to game."""
+ self.players.append(user)
+ self.player_tiles[user.id] = 1
+
+ avatar_bytes = await user.avatar_url_as(format='jpeg', size=PLAYER_ICON_IMAGE_SIZE).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) -> None:
+ """
+ Handle players joining the game.
+
+ Prevent player joining if they have already joined, if the game is full, or if the game is
+ in a waiting state.
+ """
+ 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) -> bool:
+ """
+ Handle players leaving the game.
+
+ Leaving is prevented if the user wasn't part of the game.
+
+ If the number of players reaches 0, the game is terminated. In this case, a sentinel boolean
+ is returned True to prevent a game from continuing after it's destroyed.
+ """
+ is_surrendered = False # Sentinel value to assist with stopping a surrendered game
+ 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) == 0:
+ await self.channel.send("**Snakes and Ladders**: The game has been surrendered!")
+ is_surrendered = True
+ self._destruct()
+
+ return is_surrendered
+ else:
+ await self.channel.send(user.mention + " You are not in the match.", delete_after=10)
+ return is_surrendered
+
+ async def cancel_game(self) -> None:
+ """Cancel the running game."""
+ await self.channel.send("**Snakes and Ladders**: Game has been canceled.")
+ self._destruct()
+
+ async def start_game(self, user: Member) -> None:
+ """
+ Allow the game author to begin the game.
+
+ The game cannot be started if the game is in a waiting state.
+ """
+ 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 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) -> None:
+ """Begin the round."""
+ def game_event_check(reaction_: Reaction, user_: Member) -> bool:
+ """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(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))
+
+ board_file = File(frame_to_png_bytes(board_img), 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)
+
+ is_surrendered = False
+ 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._is_moderator(user) and user not in self.players:
+ # Only allow non-playing moderation staff to cancel a running game
+ await self.cancel_game()
+ return
+ else:
+ is_surrendered = 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()
+ return # We're done, no reactions for the last 5 minutes
+
+ # Round completed
+ # Check to see if the game was surrendered before completing the round, without this
+ # sentinel, the game object would be deleted but the next round still posted into purgatory
+ if not is_surrendered:
+ await self._complete_round()
+
+ async def player_roll(self, user: Member) -> None:
+ """Handle the player's roll."""
+ 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) -> None:
+ """At the conclusion of a round check to see if there's been a winner."""
+ 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:
+ """Return a winning member if we're in the post-round state and there's a winner."""
+ 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) -> bool:
+ """Check if all members have made their roll."""
+ return all(rolled for rolled in self.round_has_rolled.values())
+
+ def _destruct(self) -> None:
+ """Clean up the finished game object."""
+ del self.snakes.active_sal[self.channel]
+
+ def _board_coordinate_from_index(self, index: int) -> Tuple[int, int]:
+ """Convert 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
+
+ @staticmethod
+ def _is_moderator(user: Member) -> bool:
+ """Return True if the user is a Moderator."""
+ return any(Roles.moderator == role.id for role in user.roles)
diff --git a/bot/exts/evergreen/space.py b/bot/exts/evergreen/space.py
new file mode 100644
index 00000000..89b31e87
--- /dev/null
+++ b/bot/exts/evergreen/space.py
@@ -0,0 +1,240 @@
+import logging
+import random
+from datetime import datetime
+from typing import Any, Dict, Optional, Union
+from urllib.parse import urlencode
+
+from discord import Embed
+from discord.ext import tasks
+from discord.ext.commands import BadArgument, Cog, Context, Converter, group
+
+from bot.bot import SeasonalBot
+from bot.constants import Tokens
+
+logger = logging.getLogger(__name__)
+
+NASA_BASE_URL = "https://api.nasa.gov"
+NASA_IMAGES_BASE_URL = "https://images-api.nasa.gov"
+NASA_EPIC_BASE_URL = "https://epic.gsfc.nasa.gov"
+
+
+class DateConverter(Converter):
+ """Parse SOL or earth date (in format YYYY-MM-DD) into `int` or `datetime`. When invalid input, raise error."""
+
+ async def convert(self, ctx: Context, argument: str) -> Union[int, datetime]:
+ """Parse date (SOL or earth) into `datetime` or `int`. When invalid value, raise error."""
+ if argument.isdigit():
+ return int(argument)
+ try:
+ date = datetime.strptime(argument, "%Y-%m-%d")
+ except ValueError:
+ raise BadArgument(f"Can't convert `{argument}` to `datetime` in format `YYYY-MM-DD` or `int` in SOL.")
+ return date
+
+
+class Space(Cog):
+ """Space Cog contains commands, that show images, facts or other information about space."""
+
+ def __init__(self, bot: SeasonalBot):
+ self.bot = bot
+ self.http_session = bot.http_session
+
+ self.rovers = {}
+ self.get_rovers.start()
+
+ def cog_unload(self) -> None:
+ """Cancel `get_rovers` task when Cog will unload."""
+ self.get_rovers.cancel()
+
+ @tasks.loop(hours=24)
+ async def get_rovers(self) -> None:
+ """Get listing of rovers from NASA API and info about their start and end dates."""
+ data = await self.fetch_from_nasa("mars-photos/api/v1/rovers")
+
+ for rover in data["rovers"]:
+ self.rovers[rover["name"].lower()] = {
+ "min_date": rover["landing_date"],
+ "max_date": rover["max_date"],
+ "max_sol": rover["max_sol"]
+ }
+
+ @group(name="space", invoke_without_command=True)
+ async def space(self, ctx: Context) -> None:
+ """Head command that contains commands about space."""
+ await ctx.send_help("space")
+
+ @space.command(name="apod")
+ async def apod(self, ctx: Context, date: Optional[str] = None) -> None:
+ """
+ Get Astronomy Picture of Day from NASA API. Date is optional parameter, what formatting is YYYY-MM-DD.
+
+ If date is not specified, this will get today APOD.
+ """
+ params = {}
+ # Parse date to params, when provided. Show error message when invalid formatting
+ if date:
+ try:
+ params["date"] = datetime.strptime(date, "%Y-%m-%d").date().isoformat()
+ except ValueError:
+ await ctx.send(f"Invalid date {date}. Please make sure your date is in format YYYY-MM-DD.")
+ return
+
+ result = await self.fetch_from_nasa("planetary/apod", params)
+
+ await ctx.send(
+ embed=self.create_nasa_embed(
+ f"Astronomy Picture of the Day - {result['date']}",
+ result["explanation"],
+ result["url"]
+ )
+ )
+
+ @space.command(name="nasa")
+ async def nasa(self, ctx: Context, *, search_term: Optional[str] = None) -> None:
+ """Get random NASA information/facts + image. Support `search_term` parameter for more specific search."""
+ params = {
+ "media_type": "image"
+ }
+ if search_term:
+ params["q"] = search_term
+
+ # Don't use API key, no need for this.
+ data = await self.fetch_from_nasa("search", params, NASA_IMAGES_BASE_URL, use_api_key=False)
+ if len(data["collection"]["items"]) == 0:
+ await ctx.send(f"Can't find any items with search term `{search_term}`.")
+ return
+
+ item = random.choice(data["collection"]["items"])
+
+ await ctx.send(
+ embed=self.create_nasa_embed(
+ item["data"][0]["title"],
+ item["data"][0]["description"],
+ item["links"][0]["href"]
+ )
+ )
+
+ @space.command(name="epic")
+ async def epic(self, ctx: Context, date: Optional[str] = None) -> None:
+ """Get one of latest random image of earth from NASA EPIC API. Support date parameter, format is YYYY-MM-DD."""
+ if date:
+ try:
+ show_date = datetime.strptime(date, "%Y-%m-%d").date().isoformat()
+ except ValueError:
+ await ctx.send(f"Invalid date {date}. Please make sure your date is in format YYYY-MM-DD.")
+ return
+ else:
+ show_date = None
+
+ # Don't use API key, no need for this.
+ data = await self.fetch_from_nasa(
+ f"api/natural{f'/date/{show_date}' if show_date else ''}",
+ base=NASA_EPIC_BASE_URL,
+ use_api_key=False
+ )
+ if len(data) < 1:
+ await ctx.send("Can't find any images in this date.")
+ return
+
+ item = random.choice(data)
+
+ year, month, day = item["date"].split(" ")[0].split("-")
+ image_url = f"{NASA_EPIC_BASE_URL}/archive/natural/{year}/{month}/{day}/jpg/{item['image']}.jpg"
+
+ await ctx.send(
+ embed=self.create_nasa_embed(
+ "Earth Image", item["caption"], image_url, f" \u2022 Identifier: {item['identifier']}"
+ )
+ )
+
+ @space.group(name="mars", invoke_without_command=True)
+ async def mars(
+ self,
+ ctx: Context,
+ date: Optional[DateConverter] = None,
+ rover: Optional[str] = "curiosity"
+ ) -> None:
+ """
+ Get random Mars image by date. Support both SOL (martian solar day) and earth date and rovers.
+
+ Earth date formatting is YYYY-MM-DD. Use `.space mars dates` to get all currently available rovers.
+ """
+ rover = rover.lower()
+ if rover not in self.rovers:
+ await ctx.send(
+ (
+ f"Invalid rover `{rover}`.\n"
+ f"**Rovers:** `{'`, `'.join(f'{r.capitalize()}' for r in self.rovers)}`"
+ )
+ )
+ return
+
+ # When date not provided, get random SOL date between 0 and rover's max.
+ if date is None:
+ date = random.randint(0, self.rovers[rover]["max_sol"])
+
+ params = {}
+ if isinstance(date, int):
+ params["sol"] = date
+ else:
+ params["earth_date"] = date.date().isoformat()
+
+ result = await self.fetch_from_nasa(f"mars-photos/api/v1/rovers/{rover}/photos", params)
+ if len(result["photos"]) < 1:
+ err_msg = (
+ f"We can't find result in date "
+ f"{date.date().isoformat() if isinstance(date, datetime) else f'{date} SOL'}.\n"
+ f"**Note:** Dates must match with rover's working dates. Please use `{ctx.prefix}space mars dates` to "
+ "see working dates for each rover."
+ )
+ await ctx.send(err_msg)
+ return
+
+ item = random.choice(result["photos"])
+ await ctx.send(
+ embed=self.create_nasa_embed(
+ f"{item['rover']['name']}'s {item['camera']['full_name']} Mars Image", "", item["img_src"],
+ )
+ )
+
+ @mars.command(name="dates", aliases=["d", "date", "rover", "rovers", "r"])
+ async def dates(self, ctx: Context) -> None:
+ """Get current available rovers photo date ranges."""
+ await ctx.send("\n".join(
+ f"**{r.capitalize()}:** {i['min_date']} **-** {i['max_date']}" for r, i in self.rovers.items()
+ ))
+
+ async def fetch_from_nasa(
+ self,
+ endpoint: str,
+ additional_params: Optional[Dict[str, Any]] = None,
+ base: Optional[str] = NASA_BASE_URL,
+ use_api_key: bool = True
+ ) -> Dict[str, Any]:
+ """Fetch information from NASA API, return result."""
+ params = {}
+ if use_api_key:
+ params["api_key"] = Tokens.nasa
+
+ # Add additional parameters to request parameters only when they provided by user
+ if additional_params is not None:
+ params.update(additional_params)
+
+ async with self.http_session.get(url=f"{base}/{endpoint}?{urlencode(params)}") as resp:
+ return await resp.json()
+
+ def create_nasa_embed(self, title: str, description: str, image: str, footer: Optional[str] = "") -> Embed:
+ """Generate NASA commands embeds. Required: title, description and image URL, footer (addition) is optional."""
+ return Embed(
+ title=title,
+ description=description
+ ).set_image(url=image).set_footer(text="Powered by NASA API" + footer)
+
+
+def setup(bot: SeasonalBot) -> None:
+ """Load Space Cog."""
+ if not Tokens.nasa:
+ logger.warning("Can't find NASA API key. Not loading Space Cog.")
+ return
+
+ bot.add_cog(Space(bot))
diff --git a/bot/exts/evergreen/speedrun.py b/bot/exts/evergreen/speedrun.py
new file mode 100644
index 00000000..4e8d7aee
--- /dev/null
+++ b/bot/exts/evergreen/speedrun.py
@@ -0,0 +1,27 @@
+import json
+import logging
+from pathlib import Path
+from random import choice
+
+from discord.ext import commands
+
+log = logging.getLogger(__name__)
+with Path('bot/resources/evergreen/speedrun_links.json').open(encoding="utf-8") as file:
+ LINKS = json.load(file)
+
+
+class Speedrun(commands.Cog):
+ """Commands about the video game speedrunning community."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @commands.command(name="speedrun")
+ async def get_speedrun(self, ctx: commands.Context) -> None:
+ """Sends a link to a video of a random speedrun."""
+ await ctx.send(choice(LINKS))
+
+
+def setup(bot: commands.Bot) -> None:
+ """Load the Speedrun cog."""
+ bot.add_cog(Speedrun(bot))
diff --git a/bot/exts/evergreen/trivia_quiz.py b/bot/exts/evergreen/trivia_quiz.py
new file mode 100644
index 00000000..c1a271e8
--- /dev/null
+++ b/bot/exts/evergreen/trivia_quiz.py
@@ -0,0 +1,302 @@
+import asyncio
+import json
+import logging
+import random
+from pathlib import Path
+
+import discord
+from discord.ext import commands
+from fuzzywuzzy import fuzz
+
+from bot.constants import Roles
+
+
+logger = logging.getLogger(__name__)
+
+
+WRONG_ANS_RESPONSE = [
+ "No one answered correctly!",
+ "Better luck next time"
+]
+
+
+class TriviaQuiz(commands.Cog):
+ """A cog for all quiz commands."""
+
+ def __init__(self, bot: commands.Bot) -> None:
+ self.bot = bot
+ self.questions = self.load_questions()
+ self.game_status = {} # A variable to store the game status: either running or not running.
+ self.game_owners = {} # A variable to store the person's ID who started the quiz game in a channel.
+ self.question_limit = 4
+ self.player_scores = {} # A variable to store all player's scores for a bot session.
+ self.game_player_scores = {} # A variable to store temporary game player's scores.
+ self.categories = {
+ "general": "Test your general knowledge"
+ # "retro": "Questions related to retro gaming."
+ }
+
+ @staticmethod
+ def load_questions() -> dict:
+ """Load the questions from the JSON file."""
+ p = Path("bot", "resources", "evergreen", "trivia_quiz.json")
+ with p.open() as json_data:
+ questions = json.load(json_data)
+ return questions
+
+ @commands.group(name="quiz", aliases=["trivia"], invoke_without_command=True)
+ async def quiz_game(self, ctx: commands.Context, category: str = None) -> None:
+ """
+ Start a quiz!
+
+ Questions for the quiz can be selected from the following categories:
+ - general : Test your general knowledge. (default)
+ (More to come!)
+ """
+ if ctx.channel.id not in self.game_status:
+ self.game_status[ctx.channel.id] = False
+
+ if ctx.channel.id not in self.game_player_scores:
+ self.game_player_scores[ctx.channel.id] = {}
+
+ # Stop game if running.
+ if self.game_status[ctx.channel.id] is True:
+ return await ctx.send(
+ f"Game is already running..."
+ f"do `{self.bot.command_prefix}quiz stop`"
+ )
+
+ # Send embed showing available categories if inputted category is invalid.
+ if category is None:
+ category = random.choice(list(self.categories))
+
+ category = category.lower()
+ if category not in self.categories:
+ embed = self.category_embed()
+ await ctx.send(embed=embed)
+ return
+
+ # Start game if not running.
+ if self.game_status[ctx.channel.id] is False:
+ self.game_owners[ctx.channel.id] = ctx.author
+ self.game_status[ctx.channel.id] = True
+ start_embed = self.make_start_embed(category)
+
+ await ctx.send(embed=start_embed) # send an embed with the rules
+ await asyncio.sleep(1)
+
+ topic = self.questions[category]
+
+ done_question = []
+ hint_no = 0
+ answer = None
+ while self.game_status[ctx.channel.id]:
+ # Exit quiz if number of questions for a round are already sent.
+ if len(done_question) > self.question_limit and hint_no == 0:
+ await ctx.send("The round has ended.")
+ await self.declare_winner(ctx.channel, self.game_player_scores[ctx.channel.id])
+
+ self.game_status[ctx.channel.id] = False
+ del self.game_owners[ctx.channel.id]
+ self.game_player_scores[ctx.channel.id] = {}
+
+ break
+
+ # If no hint has been sent or any time alert. Basically if hint_no = 0 means it is a new question.
+ if hint_no == 0:
+ # Select a random question which has not been used yet.
+ while True:
+ question_dict = random.choice(topic)
+ if question_dict["id"] not in done_question:
+ done_question.append(question_dict["id"])
+ break
+
+ q = question_dict["question"]
+ answer = question_dict["answer"]
+
+ embed = discord.Embed(colour=discord.Colour.gold())
+ embed.title = f"Question #{len(done_question)}"
+ embed.description = q
+ await ctx.send(embed=embed) # Send question embed.
+
+ # A function to check whether user input is the correct answer(close to the right answer)
+ def check(m: discord.Message) -> bool:
+ ratio = fuzz.ratio(answer.lower(), m.content.lower())
+ return ratio > 85 and m.channel == ctx.channel
+
+ try:
+ msg = await self.bot.wait_for('message', check=check, timeout=10)
+ except asyncio.TimeoutError:
+ # In case of TimeoutError and the game has been stopped, then do nothing.
+ if self.game_status[ctx.channel.id] is False:
+ break
+
+ # if number of hints sent or time alerts sent is less than 2, then send one.
+ if hint_no < 2:
+ hint_no += 1
+ if "hints" in question_dict:
+ hints = question_dict["hints"]
+ await ctx.send(f"**Hint #{hint_no+1}\n**{hints[hint_no]}")
+ else:
+ await ctx.send(f"{30 - hint_no * 10}s left!")
+
+ # Once hint or time alerts has been sent 2 times, the hint_no value will be 3
+ # If hint_no > 2, then it means that all hints/time alerts have been sent.
+ # Also means that the answer is not yet given and the bot sends the answer and the next question.
+ else:
+ if self.game_status[ctx.channel.id] is False:
+ break
+
+ response = random.choice(WRONG_ANS_RESPONSE)
+ await ctx.send(response)
+ await self.send_answer(ctx.channel, question_dict)
+ await asyncio.sleep(1)
+
+ hint_no = 0 # init hint_no = 0 so that 2 hints/time alerts can be sent for the new question.
+
+ await self.send_score(ctx.channel, self.game_player_scores[ctx.channel.id])
+ await asyncio.sleep(2)
+ else:
+ if self.game_status[ctx.channel.id] is False:
+ break
+
+ # Reduce points by 25 for every hint/time alert that has been sent.
+ points = 100 - 25*hint_no
+ if msg.author in self.game_player_scores[ctx.channel.id]:
+ self.game_player_scores[ctx.channel.id][msg.author] += points
+ else:
+ self.game_player_scores[ctx.channel.id][msg.author] = points
+
+ # Also updating the overall scoreboard.
+ if msg.author in self.player_scores:
+ self.player_scores[msg.author] += points
+ else:
+ self.player_scores[msg.author] = points
+
+ hint_no = 0
+
+ await ctx.send(f"{msg.author.mention} got the correct answer :tada: {points} points!")
+ await self.send_answer(ctx.channel, question_dict)
+ await self.send_score(ctx.channel, self.game_player_scores[ctx.channel.id])
+ await asyncio.sleep(2)
+
+ @staticmethod
+ def make_start_embed(category: str) -> discord.Embed:
+ """Generate a starting/introduction embed for the quiz."""
+ start_embed = discord.Embed(colour=discord.Colour.red())
+ start_embed.title = "Quiz game Starting!!"
+ start_embed.description = "Each game consists of 5 questions.\n"
+ start_embed.description += "**Rules :**\nNo cheating and have fun!"
+ start_embed.description += f"\n **Category** : {category}"
+ start_embed.set_footer(
+ text="Points for each question reduces by 25 after 10s or after a hint. Total time is 30s per question"
+ )
+ return start_embed
+
+ @quiz_game.command(name="stop")
+ async def stop_quiz(self, ctx: commands.Context) -> None:
+ """
+ Stop a quiz game if its running in the channel.
+
+ Note: Only mods or the owner of the quiz can stop it.
+ """
+ if self.game_status[ctx.channel.id] is True:
+ # Check if the author is the game starter or a moderator.
+ if (
+ ctx.author == self.game_owners[ctx.channel.id]
+ or any(Roles.moderator == role.id for role in ctx.author.roles)
+ ):
+ await ctx.send("Quiz stopped.")
+ await self.declare_winner(ctx.channel, self.game_player_scores[ctx.channel.id])
+
+ self.game_status[ctx.channel.id] = False
+ del self.game_owners[ctx.channel.id]
+ self.game_player_scores[ctx.channel.id] = {}
+ else:
+ await ctx.send(f"{ctx.author.mention}, you are not authorised to stop this game :ghost:!")
+ else:
+ await ctx.send("No quiz running.")
+
+ @quiz_game.command(name="leaderboard")
+ async def leaderboard(self, ctx: commands.Context) -> None:
+ """View everyone's score for this bot session."""
+ await self.send_score(ctx.channel, self.player_scores)
+
+ @staticmethod
+ async def send_score(channel: discord.TextChannel, player_data: dict) -> None:
+ """A function which sends the score."""
+ if len(player_data) == 0:
+ await channel.send("No one has made it onto the leaderboard yet.")
+ return
+
+ embed = discord.Embed(colour=discord.Colour.blue())
+ embed.title = "Score Board"
+ embed.description = ""
+
+ sorted_dict = sorted(player_data.items(), key=lambda a: a[1], reverse=True)
+ for item in sorted_dict:
+ embed.description += f"{item[0]} : {item[1]}\n"
+
+ await channel.send(embed=embed)
+
+ @staticmethod
+ async def declare_winner(channel: discord.TextChannel, player_data: dict) -> None:
+ """Announce the winner of the quiz in the game channel."""
+ if player_data:
+ highest_points = max(list(player_data.values()))
+ no_of_winners = list(player_data.values()).count(highest_points)
+
+ # Check if more than 1 player has highest points.
+ if no_of_winners > 1:
+ word = "You guys"
+ winners = []
+ points_copy = list(player_data.values()).copy()
+
+ for _ in range(no_of_winners):
+ index = points_copy.index(highest_points)
+ winners.append(list(player_data.keys())[index])
+ points_copy[index] = 0
+
+ winners_mention = " ".join(winner.mention for winner in winners)
+ else:
+ word = "You"
+ author_index = list(player_data.values()).index(highest_points)
+ winner = list(player_data.keys())[author_index]
+ winners_mention = winner.mention
+
+ await channel.send(
+ f"Congratulations {winners_mention} :tada: "
+ f"{word} have won this quiz game with a grand total of {highest_points} points!"
+ )
+
+ def category_embed(self) -> discord.Embed:
+ """Build an embed showing all available trivia categories."""
+ embed = discord.Embed(colour=discord.Colour.blue())
+ embed.title = "The available question categories are:"
+ embed.set_footer(text="If a category is not chosen, a random one will be selected.")
+ embed.description = ""
+
+ for cat, description in self.categories.items():
+ embed.description += f"**- {cat.capitalize()}**\n{description.capitalize()}\n"
+
+ return embed
+
+ @staticmethod
+ async def send_answer(channel: discord.TextChannel, question_dict: dict) -> None:
+ """Send the correct answer of a question to the game channel."""
+ answer = question_dict["answer"]
+ info = question_dict["info"]
+ embed = discord.Embed(color=discord.Colour.red())
+ embed.title = f"The correct answer is **{answer}**\n"
+ embed.description = ""
+
+ if info != "":
+ embed.description += f"**Information**\n{info}\n\n"
+
+ embed.description += "Let's move to the next question.\nRemaining questions: "
+ await channel.send(embed=embed)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Load the cog."""
+ bot.add_cog(TriviaQuiz(bot))
diff --git a/bot/exts/evergreen/uptime.py b/bot/exts/evergreen/uptime.py
new file mode 100644
index 00000000..a9ad9dfb
--- /dev/null
+++ b/bot/exts/evergreen/uptime.py
@@ -0,0 +1,33 @@
+import logging
+
+import arrow
+from dateutil.relativedelta import relativedelta
+from discord.ext import commands
+
+from bot import start_time
+
+log = logging.getLogger(__name__)
+
+
+class Uptime(commands.Cog):
+ """A cog for posting the bot's uptime."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @commands.command(name="uptime")
+ async def uptime(self, ctx: commands.Context) -> None:
+ """Responds with the uptime of the bot."""
+ difference = relativedelta(start_time - arrow.utcnow())
+ uptime_string = start_time.shift(
+ seconds=-difference.seconds,
+ minutes=-difference.minutes,
+ hours=-difference.hours,
+ days=-difference.days
+ ).humanize()
+ await ctx.send(f"I started up {uptime_string}.")
+
+
+def setup(bot: commands.Bot) -> None:
+ """Uptime Cog load."""
+ bot.add_cog(Uptime(bot))
diff --git a/bot/exts/halloween/8ball.py b/bot/exts/halloween/8ball.py
new file mode 100644
index 00000000..1df48fbf
--- /dev/null
+++ b/bot/exts/halloween/8ball.py
@@ -0,0 +1,33 @@
+import asyncio
+import json
+import logging
+import random
+from pathlib import Path
+
+from discord.ext import commands
+
+log = logging.getLogger(__name__)
+
+with open(Path("bot/resources/halloween/responses.json"), "r", encoding="utf8") as f:
+ responses = json.load(f)
+
+
+class SpookyEightBall(commands.Cog):
+ """Spooky Eightball answers."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @commands.command(aliases=('spooky8ball',))
+ async def spookyeightball(self, ctx: commands.Context, *, question: str) -> None:
+ """Responds with a random response to a question."""
+ choice = random.choice(responses['responses'])
+ msg = await ctx.send(choice[0])
+ if len(choice) > 1:
+ await asyncio.sleep(random.randint(2, 5))
+ await msg.edit(content=f"{choice[0]} \n{choice[1]}")
+
+
+def setup(bot: commands.Bot) -> None:
+ """Spooky Eight Ball Cog Load."""
+ bot.add_cog(SpookyEightBall(bot))
diff --git a/bot/exts/halloween/__init__.py b/bot/exts/halloween/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bot/exts/halloween/__init__.py
diff --git a/bot/exts/halloween/candy_collection.py b/bot/exts/halloween/candy_collection.py
new file mode 100644
index 00000000..90c29eb2
--- /dev/null
+++ b/bot/exts/halloween/candy_collection.py
@@ -0,0 +1,224 @@
+import functools
+import json
+import logging
+import os
+import random
+from typing import List, Union
+
+import discord
+from discord.ext import commands
+
+from bot.constants import Channels, Month
+from bot.utils.decorators import in_month
+
+log = logging.getLogger(__name__)
+
+json_location = os.path.join("bot", "resources", "halloween", "candy_collection.json")
+
+# chance is 1 in x range, so 1 in 20 range would give 5% chance (for add candy)
+ADD_CANDY_REACTION_CHANCE = 20 # 5%
+ADD_CANDY_EXISTING_REACTION_CHANCE = 10 # 10%
+ADD_SKULL_REACTION_CHANCE = 50 # 2%
+ADD_SKULL_EXISTING_REACTION_CHANCE = 20 # 5%
+
+
+class CandyCollection(commands.Cog):
+ """Candy collection game Cog."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+ with open(json_location) as candy:
+ self.candy_json = json.load(candy)
+ self.msg_reacted = self.candy_json['msg_reacted']
+ self.get_candyinfo = dict()
+ for userinfo in self.candy_json['records']:
+ userid = userinfo['userid']
+ self.get_candyinfo[userid] = userinfo
+
+ @in_month(Month.OCTOBER)
+ @commands.Cog.listener()
+ async def on_message(self, message: discord.Message) -> None:
+ """Randomly adds candy or skull reaction to non-bot messages in the Event channel."""
+ # make sure its a human message
+ if message.author.bot:
+ return
+ # ensure it's hacktober channel
+ if message.channel.id != Channels.seasonalbot_commands:
+ return
+
+ # do random check for skull first as it has the lower chance
+ if random.randint(1, ADD_SKULL_REACTION_CHANCE) == 1:
+ d = {"reaction": '\N{SKULL}', "msg_id": message.id, "won": False}
+ self.msg_reacted.append(d)
+ return await message.add_reaction('\N{SKULL}')
+ # check for the candy chance next
+ if random.randint(1, ADD_CANDY_REACTION_CHANCE) == 1:
+ d = {"reaction": '\N{CANDY}', "msg_id": message.id, "won": False}
+ self.msg_reacted.append(d)
+ return await message.add_reaction('\N{CANDY}')
+
+ @in_month(Month.OCTOBER)
+ @commands.Cog.listener()
+ async def on_reaction_add(self, reaction: discord.Reaction, user: discord.Member) -> None:
+ """Add/remove candies from a person if the reaction satisfies criteria."""
+ message = reaction.message
+ # check to ensure the reactor is human
+ if user.bot:
+ return
+
+ # check to ensure it is in correct channel
+ if message.channel.id != Channels.seasonalbot_commands:
+ return
+
+ # if its not a candy or skull, and it is one of 10 most recent messages,
+ # proceed to add a skull/candy with higher chance
+ if str(reaction.emoji) not in ('\N{SKULL}', '\N{CANDY}'):
+ if message.id in await self.ten_recent_msg():
+ await self.reacted_msg_chance(message)
+ return
+
+ for react in self.msg_reacted:
+ # check to see if the message id of a message we added a
+ # reaction to is in json file, and if nobody has won/claimed it yet
+ if react['msg_id'] == message.id and react['won'] is False:
+ react['user_reacted'] = user.id
+ react['won'] = True
+ try:
+ # if they have record/candies in json already it will do this
+ user_records = self.get_candyinfo[user.id]
+ if str(reaction.emoji) == '\N{CANDY}':
+ user_records['record'] += 1
+ if str(reaction.emoji) == '\N{SKULL}':
+ if user_records['record'] <= 3:
+ user_records['record'] = 0
+ lost = 'all of your'
+ else:
+ lost = random.randint(1, 3)
+ user_records['record'] -= lost
+ await self.send_spook_msg(message.author, message.channel, lost)
+
+ except KeyError:
+ # otherwise it will raise KeyError so we need to add them to file
+ if str(reaction.emoji) == '\N{CANDY}':
+ print('ok')
+ d = {"userid": user.id, "record": 1}
+ self.candy_json['records'].append(d)
+ await self.remove_reactions(reaction)
+
+ async def reacted_msg_chance(self, message: discord.Message) -> None:
+ """
+ Randomly add a skull or candy reaction to a message if there is a reaction there already.
+
+ This event has a higher probability of occurring than a reaction add to a message without an
+ existing reaction.
+ """
+ if random.randint(1, ADD_SKULL_EXISTING_REACTION_CHANCE) == 1:
+ d = {"reaction": '\N{SKULL}', "msg_id": message.id, "won": False}
+ self.msg_reacted.append(d)
+ return await message.add_reaction('\N{SKULL}')
+
+ if random.randint(1, ADD_CANDY_EXISTING_REACTION_CHANCE) == 1:
+ d = {"reaction": '\N{CANDY}', "msg_id": message.id, "won": False}
+ self.msg_reacted.append(d)
+ return await message.add_reaction('\N{CANDY}')
+
+ async def ten_recent_msg(self) -> List[int]:
+ """Get the last 10 messages sent in the channel."""
+ ten_recent = []
+ recent_msg_id = max(
+ message.id for message in self.bot._connection._messages
+ if message.channel.id == Channels.seasonalbot_commands
+ )
+
+ channel = await self.hacktober_channel()
+ ten_recent.append(recent_msg_id)
+
+ for i in range(9):
+ o = discord.Object(id=recent_msg_id + i)
+ msg = await next(channel.history(limit=1, before=o))
+ ten_recent.append(msg.id)
+
+ return ten_recent
+
+ async def get_message(self, msg_id: int) -> Union[discord.Message, None]:
+ """Get the message from its ID."""
+ try:
+ o = discord.Object(id=msg_id + 1)
+ # Use history rather than get_message due to
+ # poor ratelimit (50/1s vs 1/1s)
+ msg = await next(self.hacktober_channel.history(limit=1, before=o))
+
+ if msg.id != msg_id:
+ return None
+
+ return msg
+
+ except Exception:
+ return None
+
+ async def hacktober_channel(self) -> discord.TextChannel:
+ """Get #hacktoberbot channel from its ID."""
+ return self.bot.get_channel(id=Channels.seasonalbot_commands)
+
+ async def remove_reactions(self, reaction: discord.Reaction) -> None:
+ """Remove all candy/skull reactions."""
+ try:
+ async for user in reaction.users():
+ await reaction.message.remove_reaction(reaction.emoji, user)
+
+ except discord.HTTPException:
+ pass
+
+ async def send_spook_msg(self, author: discord.Member, channel: discord.TextChannel, candies: int) -> None:
+ """Send a spooky message."""
+ e = discord.Embed(colour=author.colour)
+ e.set_author(name="Ghosts and Ghouls and Jack o' lanterns at night; "
+ f"I took {candies} candies and quickly took flight.")
+ await channel.send(embed=e)
+
+ def save_to_json(self) -> None:
+ """Save JSON to a local file."""
+ with open(json_location, 'w') as outfile:
+ json.dump(self.candy_json, outfile)
+
+ @in_month(Month.OCTOBER)
+ @commands.command()
+ async def candy(self, ctx: commands.Context) -> None:
+ """Get the candy leaderboard and save to JSON."""
+ # Use run_in_executor to prevent blocking
+ thing = functools.partial(self.save_to_json)
+ await self.bot.loop.run_in_executor(None, thing)
+
+ emoji = (
+ '\N{FIRST PLACE MEDAL}',
+ '\N{SECOND PLACE MEDAL}',
+ '\N{THIRD PLACE MEDAL}',
+ '\N{SPORTS MEDAL}',
+ '\N{SPORTS MEDAL}'
+ )
+
+ top_sorted = sorted(self.candy_json['records'], key=lambda k: k.get('record', 0), reverse=True)
+ top_five = top_sorted[:5]
+
+ usersid = []
+ records = []
+ for record in top_five:
+ usersid.append(record['userid'])
+ records.append(record['record'])
+
+ value = '\n'.join(f'{emoji[index]} <@{usersid[index]}>: {records[index]}'
+ for index in range(0, len(usersid))) or 'No Candies'
+
+ e = discord.Embed(colour=discord.Colour.blurple())
+ e.add_field(name="Top Candy Records", value=value, inline=False)
+ e.add_field(name='\u200b',
+ value=f"Candies will randomly appear on messages sent. "
+ f"\nHit the candy when it appears as fast as possible to get the candy! "
+ f"\nBut beware the ghosts...",
+ inline=False)
+ await ctx.send(embed=e)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Candy Collection game Cog load."""
+ bot.add_cog(CandyCollection(bot))
diff --git a/bot/exts/halloween/hacktober-issue-finder.py b/bot/exts/halloween/hacktober-issue-finder.py
new file mode 100644
index 00000000..b5ad1c4f
--- /dev/null
+++ b/bot/exts/halloween/hacktober-issue-finder.py
@@ -0,0 +1,110 @@
+import datetime
+import logging
+import random
+from typing import Dict, Optional
+
+import aiohttp
+import discord
+from discord.ext import commands
+
+from bot.constants import Month
+from bot.utils.decorators import in_month
+
+log = logging.getLogger(__name__)
+
+URL = "https://api.github.com/search/issues?per_page=100&q=is:issue+label:hacktoberfest+language:python+state:open"
+HEADERS = {"Accept": "application / vnd.github.v3 + json"}
+
+
+class HacktoberIssues(commands.Cog):
+ """Find a random hacktober python issue on GitHub."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+ self.cache_normal = None
+ self.cache_timer_normal = datetime.datetime(1, 1, 1)
+ self.cache_beginner = None
+ self.cache_timer_beginner = datetime.datetime(1, 1, 1)
+
+ @in_month(Month.OCTOBER)
+ @commands.command()
+ async def hacktoberissues(self, ctx: commands.Context, option: str = "") -> None:
+ """
+ Get a random python hacktober issue from Github.
+
+ If the command is run with beginner (`.hacktoberissues beginner`):
+ It will also narrow it down to the "first good issue" label.
+ """
+ with ctx.typing():
+ issues = await self.get_issues(ctx, option)
+ if issues is None:
+ return
+ issue = random.choice(issues["items"])
+ embed = self.format_embed(issue)
+ await ctx.send(embed=embed)
+
+ async def get_issues(self, ctx: commands.Context, option: str) -> Optional[Dict]:
+ """Get a list of the python issues with the label 'hacktoberfest' from the Github api."""
+ if option == "beginner":
+ if (ctx.message.created_at - self.cache_timer_beginner).seconds <= 60:
+ log.debug("using cache")
+ return self.cache_beginner
+ elif (ctx.message.created_at - self.cache_timer_normal).seconds <= 60:
+ log.debug("using cache")
+ return self.cache_normal
+
+ async with aiohttp.ClientSession() as session:
+ if option == "beginner":
+ url = URL + '+label:"good first issue"'
+ if self.cache_beginner is not None:
+ page = random.randint(1, min(1000, self.cache_beginner["total_count"]) // 100)
+ url += f"&page={page}"
+ else:
+ url = URL
+ if self.cache_normal is not None:
+ page = random.randint(1, min(1000, self.cache_normal["total_count"]) // 100)
+ url += f"&page={page}"
+
+ log.debug(f"making api request to url: {url}")
+ async with session.get(url, headers=HEADERS) as response:
+ if response.status != 200:
+ log.error(f"expected 200 status (got {response.status}) from the GitHub api.")
+ await ctx.send(f"ERROR: expected 200 status (got {response.status}) from the GitHub api.")
+ await ctx.send(await response.text())
+ return None
+ data = await response.json()
+
+ if len(data["items"]) == 0:
+ log.error(f"no issues returned from GitHub api. with url: {response.url}")
+ await ctx.send(f"ERROR: no issues returned from GitHub api. with url: {response.url}")
+ return None
+
+ if option == "beginner":
+ self.cache_beginner = data
+ self.cache_timer_beginner = ctx.message.created_at
+ else:
+ self.cache_normal = data
+ self.cache_timer_normal = ctx.message.created_at
+
+ return data
+
+ @staticmethod
+ def format_embed(issue: Dict) -> discord.Embed:
+ """Format the issue data into a embed."""
+ title = issue["title"]
+ issue_url = issue["url"].replace("api.", "").replace("/repos/", "/")
+ body = issue["body"]
+ labels = [label["name"] for label in issue["labels"]]
+
+ embed = discord.Embed(title=title)
+ embed.description = body
+ embed.add_field(name="labels", value="\n".join(labels))
+ embed.url = issue_url
+ embed.set_footer(text=issue_url)
+
+ return embed
+
+
+def setup(bot: commands.Bot) -> None:
+ """Hacktober issue finder Cog Load."""
+ bot.add_cog(HacktoberIssues(bot))
diff --git a/bot/exts/halloween/hacktoberstats.py b/bot/exts/halloween/hacktoberstats.py
new file mode 100644
index 00000000..e01ee50c
--- /dev/null
+++ b/bot/exts/halloween/hacktoberstats.py
@@ -0,0 +1,341 @@
+import json
+import logging
+import re
+from collections import Counter
+from datetime import datetime
+from pathlib import Path
+from typing import List, Tuple
+
+import aiohttp
+import discord
+from discord.ext import commands
+
+from bot.constants import Channels, Month, WHITELISTED_CHANNELS
+from bot.utils.decorators import in_month, override_in_channel
+from bot.utils.persist import make_persistent
+
+log = logging.getLogger(__name__)
+
+CURRENT_YEAR = datetime.now().year # Used to construct GH API query
+PRS_FOR_SHIRT = 4 # Minimum number of PRs before a shirt is awarded
+HACKTOBER_WHITELIST = WHITELISTED_CHANNELS + (Channels.hacktoberfest_2019,)
+
+
+class HacktoberStats(commands.Cog):
+ """Hacktoberfest statistics Cog."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+ self.link_json = make_persistent(Path("bot", "resources", "halloween", "github_links.json"))
+ self.linked_accounts = self.load_linked_users()
+
+ @in_month(Month.OCTOBER)
+ @commands.group(name="hacktoberstats", aliases=("hackstats",), invoke_without_command=True)
+ @override_in_channel(HACKTOBER_WHITELIST)
+ async def hacktoberstats_group(self, ctx: commands.Context, github_username: str = None) -> None:
+ """
+ Display an embed for a user's Hacktoberfest contributions.
+
+ If invoked without a subcommand or github_username, get the invoking user's stats if they've
+ linked their Discord name to GitHub using .stats link. If invoked with a github_username,
+ get that user's contributions
+ """
+ if not github_username:
+ author_id, author_mention = HacktoberStats._author_mention_from_context(ctx)
+
+ if str(author_id) in self.linked_accounts.keys():
+ github_username = self.linked_accounts[author_id]["github_username"]
+ logging.info(f"Getting stats for {author_id} linked GitHub account '{github_username}'")
+ else:
+ msg = (
+ f"{author_mention}, you have not linked a GitHub account\n\n"
+ f"You can link your GitHub account using:\n```{ctx.prefix}hackstats link github_username```\n"
+ f"Or query GitHub stats directly using:\n```{ctx.prefix}hackstats github_username```"
+ )
+ await ctx.send(msg)
+ return
+
+ await self.get_stats(ctx, github_username)
+
+ @in_month(Month.OCTOBER)
+ @hacktoberstats_group.command(name="link")
+ @override_in_channel(HACKTOBER_WHITELIST)
+ async def link_user(self, ctx: commands.Context, github_username: str = None) -> None:
+ """
+ Link the invoking user's Github github_username to their Discord ID.
+
+ Linked users are stored as a nested dict:
+ {
+ Discord_ID: {
+ "github_username": str
+ "date_added": datetime
+ }
+ }
+ """
+ author_id, author_mention = HacktoberStats._author_mention_from_context(ctx)
+ if github_username:
+ if str(author_id) in self.linked_accounts.keys():
+ old_username = self.linked_accounts[author_id]["github_username"]
+ logging.info(f"{author_id} has changed their github link from '{old_username}' to '{github_username}'")
+ await ctx.send(f"{author_mention}, your GitHub username has been updated to: '{github_username}'")
+ else:
+ logging.info(f"{author_id} has added a github link to '{github_username}'")
+ await ctx.send(f"{author_mention}, your GitHub username has been added")
+
+ self.linked_accounts[author_id] = {
+ "github_username": github_username,
+ "date_added": datetime.now()
+ }
+
+ self.save_linked_users()
+ else:
+ logging.info(f"{author_id} tried to link a GitHub account but didn't provide a username")
+ await ctx.send(f"{author_mention}, a GitHub username is required to link your account")
+
+ @in_month(Month.OCTOBER)
+ @hacktoberstats_group.command(name="unlink")
+ @override_in_channel(HACKTOBER_WHITELIST)
+ async def unlink_user(self, ctx: commands.Context) -> None:
+ """Remove the invoking user's account link from the log."""
+ author_id, author_mention = HacktoberStats._author_mention_from_context(ctx)
+
+ stored_user = self.linked_accounts.pop(author_id, None)
+ if stored_user:
+ await ctx.send(f"{author_mention}, your GitHub profile has been unlinked")
+ logging.info(f"{author_id} has unlinked their GitHub account")
+ else:
+ await ctx.send(f"{author_mention}, you do not currently have a linked GitHub account")
+ logging.info(f"{author_id} tried to unlink their GitHub account but no account was linked")
+
+ self.save_linked_users()
+
+ def load_linked_users(self) -> dict:
+ """
+ Load list of linked users from local JSON file.
+
+ Linked users are stored as a nested dict:
+ {
+ Discord_ID: {
+ "github_username": str
+ "date_added": datetime
+ }
+ }
+ """
+ if self.link_json.exists():
+ logging.info(f"Loading linked GitHub accounts from '{self.link_json}'")
+ with open(self.link_json, 'r') as file:
+ linked_accounts = json.load(file)
+
+ logging.info(f"Loaded {len(linked_accounts)} linked GitHub accounts from '{self.link_json}'")
+ return linked_accounts
+ else:
+ logging.info(f"Linked account log: '{self.link_json}' does not exist")
+ return {}
+
+ def save_linked_users(self) -> None:
+ """
+ Save list of linked users to local JSON file.
+
+ Linked users are stored as a nested dict:
+ {
+ Discord_ID: {
+ "github_username": str
+ "date_added": datetime
+ }
+ }
+ """
+ logging.info(f"Saving linked_accounts to '{self.link_json}'")
+ with open(self.link_json, 'w') as file:
+ json.dump(self.linked_accounts, file, default=str)
+ logging.info(f"linked_accounts saved to '{self.link_json}'")
+
+ async def get_stats(self, ctx: commands.Context, github_username: str) -> None:
+ """
+ Query GitHub's API for PRs created by a GitHub user during the month of October.
+
+ PRs with the 'invalid' tag are ignored
+
+ If a valid github_username is provided, an embed is generated and posted to the channel
+
+ Otherwise, post a helpful error message
+ """
+ async with ctx.typing():
+ prs = await self.get_october_prs(github_username)
+
+ if prs:
+ stats_embed = self.build_embed(github_username, prs)
+ await ctx.send('Here are some stats!', embed=stats_embed)
+ else:
+ await ctx.send(f"No October GitHub contributions found for '{github_username}'")
+
+ def build_embed(self, github_username: str, prs: List[dict]) -> discord.Embed:
+ """Return a stats embed built from github_username's PRs."""
+ logging.info(f"Building Hacktoberfest embed for GitHub user: '{github_username}'")
+ pr_stats = self._summarize_prs(prs)
+
+ n = pr_stats['n_prs']
+ if n >= PRS_FOR_SHIRT:
+ shirtstr = f"**{github_username} has earned a tshirt!**"
+ elif n == PRS_FOR_SHIRT - 1:
+ shirtstr = f"**{github_username} is 1 PR away from a tshirt!**"
+ else:
+ shirtstr = f"**{github_username} is {PRS_FOR_SHIRT - n} PRs away from a tshirt!**"
+
+ stats_embed = discord.Embed(
+ title=f"{github_username}'s Hacktoberfest",
+ color=discord.Color(0x9c4af7),
+ description=(
+ f"{github_username} has made {n} "
+ f"{HacktoberStats._contributionator(n)} in "
+ f"October\n\n"
+ f"{shirtstr}\n\n"
+ )
+ )
+
+ stats_embed.set_thumbnail(url=f"https://www.github.com/{github_username}.png")
+ stats_embed.set_author(
+ name="Hacktoberfest",
+ url="https://hacktoberfest.digitalocean.com",
+ icon_url="https://hacktoberfest.digitalocean.com/pretty_logo.png"
+ )
+ stats_embed.add_field(
+ name="Top 5 Repositories:",
+ value=self._build_top5str(pr_stats)
+ )
+
+ logging.info(f"Hacktoberfest PR built for GitHub user '{github_username}'")
+ return stats_embed
+
+ @staticmethod
+ async def get_october_prs(github_username: str) -> List[dict]:
+ """
+ Query GitHub's API for PRs created during the month of October by github_username.
+
+ PRs with an 'invalid' tag are ignored
+
+ If PRs are found, return a list of dicts with basic PR information
+
+ For each PR:
+ {
+ "repo_url": str
+ "repo_shortname": str (e.g. "python-discord/seasonalbot")
+ "created_at": datetime.datetime
+ }
+
+ Otherwise, return None
+ """
+ logging.info(f"Generating Hacktoberfest PR query for GitHub user: '{github_username}'")
+ base_url = "https://api.github.com/search/issues?q="
+ not_label = "invalid"
+ action_type = "pr"
+ is_query = f"public+author:{github_username}"
+ not_query = "draft"
+ date_range = f"{CURRENT_YEAR}-10-01T00:00:00%2B14:00..{CURRENT_YEAR}-10-31T23:59:59-11:00"
+ per_page = "300"
+ query_url = (
+ f"{base_url}"
+ f"-label:{not_label}"
+ f"+type:{action_type}"
+ f"+is:{is_query}"
+ f"+-is:{not_query}"
+ f"+created:{date_range}"
+ f"&per_page={per_page}"
+ )
+
+ headers = {"user-agent": "Discord Python Hacktoberbot"}
+ async with aiohttp.ClientSession() as session:
+ async with session.get(query_url, headers=headers) as resp:
+ jsonresp = await resp.json()
+
+ if "message" in jsonresp.keys():
+ # One of the parameters is invalid, short circuit for now
+ api_message = jsonresp["errors"][0]["message"]
+ logging.error(f"GitHub API request for '{github_username}' failed with message: {api_message}")
+ return
+ else:
+ if jsonresp["total_count"] == 0:
+ # Short circuit if there aren't any PRs
+ logging.info(f"No Hacktoberfest PRs found for GitHub user: '{github_username}'")
+ return
+ else:
+ logging.info(f"Found {len(jsonresp['items'])} Hacktoberfest PRs for GitHub user: '{github_username}'")
+ outlist = []
+ for item in jsonresp["items"]:
+ shortname = HacktoberStats._get_shortname(item["repository_url"])
+ itemdict = {
+ "repo_url": f"https://www.github.com/{shortname}",
+ "repo_shortname": shortname,
+ "created_at": datetime.strptime(
+ item["created_at"], r"%Y-%m-%dT%H:%M:%SZ"
+ ),
+ }
+ outlist.append(itemdict)
+ return outlist
+
+ @staticmethod
+ def _get_shortname(in_url: str) -> str:
+ """
+ Extract shortname from https://api.github.com/repos/* URL.
+
+ e.g. "https://api.github.com/repos/python-discord/seasonalbot"
+ |
+ V
+ "python-discord/seasonalbot"
+ """
+ exp = r"https?:\/\/api.github.com\/repos\/([/\-\_\.\w]+)"
+ return re.findall(exp, in_url)[0]
+
+ @staticmethod
+ def _summarize_prs(prs: List[dict]) -> dict:
+ """
+ Generate statistics from an input list of PR dictionaries, as output by get_october_prs.
+
+ Return a dictionary containing:
+ {
+ "n_prs": int
+ "top5": [(repo_shortname, ncontributions), ...]
+ }
+ """
+ contributed_repos = [pr["repo_shortname"] for pr in prs]
+ return {"n_prs": len(prs), "top5": Counter(contributed_repos).most_common(5)}
+
+ @staticmethod
+ def _build_top5str(stats: List[tuple]) -> str:
+ """
+ Build a string from the Top 5 contributions that is compatible with a discord.Embed field.
+
+ Top 5 contributions should be a list of tuples, as output in the stats dictionary by
+ _summarize_prs
+
+ String is of the form:
+ n contribution(s) to [shortname](url)
+ ...
+ """
+ base_url = "https://www.github.com/"
+ contributionstrs = []
+ for repo in stats['top5']:
+ n = repo[1]
+ contributionstrs.append(f"{n} {HacktoberStats._contributionator(n)} to [{repo[0]}]({base_url}{repo[0]})")
+
+ return "\n".join(contributionstrs)
+
+ @staticmethod
+ def _contributionator(n: int) -> str:
+ """Return "contribution" or "contributions" based on the value of n."""
+ if n == 1:
+ return "contribution"
+ else:
+ return "contributions"
+
+ @staticmethod
+ def _author_mention_from_context(ctx: commands.Context) -> Tuple:
+ """Return stringified Message author ID and mentionable string from commands.Context."""
+ author_id = str(ctx.message.author.id)
+ author_mention = ctx.message.author.mention
+
+ return author_id, author_mention
+
+
+def setup(bot: commands.Bot) -> None:
+ """Hacktoberstats Cog load."""
+ bot.add_cog(HacktoberStats(bot))
diff --git a/bot/exts/halloween/halloween_facts.py b/bot/exts/halloween/halloween_facts.py
new file mode 100644
index 00000000..44a66ab2
--- /dev/null
+++ b/bot/exts/halloween/halloween_facts.py
@@ -0,0 +1,58 @@
+import json
+import logging
+import random
+from datetime import timedelta
+from pathlib import Path
+from typing import Tuple
+
+import discord
+from discord.ext import commands
+
+log = logging.getLogger(__name__)
+
+SPOOKY_EMOJIS = [
+ "\N{BAT}",
+ "\N{DERELICT HOUSE BUILDING}",
+ "\N{EXTRATERRESTRIAL ALIEN}",
+ "\N{GHOST}",
+ "\N{JACK-O-LANTERN}",
+ "\N{SKULL}",
+ "\N{SKULL AND CROSSBONES}",
+ "\N{SPIDER WEB}",
+]
+PUMPKIN_ORANGE = discord.Color(0xFF7518)
+INTERVAL = timedelta(hours=6).total_seconds()
+
+
+class HalloweenFacts(commands.Cog):
+ """A Cog for displaying interesting facts about Halloween."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+ with open(Path("bot/resources/halloween/halloween_facts.json"), "r") as file:
+ self.halloween_facts = json.load(file)
+ self.facts = list(enumerate(self.halloween_facts))
+ random.shuffle(self.facts)
+
+ def random_fact(self) -> Tuple[int, str]:
+ """Return a random fact from the loaded facts."""
+ return random.choice(self.facts)
+
+ @commands.command(name="spookyfact", aliases=("halloweenfact",), brief="Get the most recent Halloween fact")
+ async def get_random_fact(self, ctx: commands.Context) -> None:
+ """Reply with the most recent Halloween fact."""
+ index, fact = self.random_fact()
+ embed = self._build_embed(index, fact)
+ await ctx.send(embed=embed)
+
+ @staticmethod
+ def _build_embed(index: int, fact: str) -> discord.Embed:
+ """Builds a Discord embed from the given fact and its index."""
+ emoji = random.choice(SPOOKY_EMOJIS)
+ title = f"{emoji} Halloween Fact #{index + 1}"
+ return discord.Embed(title=title, description=fact, color=PUMPKIN_ORANGE)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Halloween facts Cog load."""
+ bot.add_cog(HalloweenFacts(bot))
diff --git a/bot/exts/halloween/halloweenify.py b/bot/exts/halloween/halloweenify.py
new file mode 100644
index 00000000..5c433a81
--- /dev/null
+++ b/bot/exts/halloween/halloweenify.py
@@ -0,0 +1,51 @@
+import logging
+from json import load
+from pathlib import Path
+from random import choice
+
+import discord
+from discord.ext import commands
+from discord.ext.commands.cooldowns import BucketType
+
+log = logging.getLogger(__name__)
+
+
+class Halloweenify(commands.Cog):
+ """A cog to change a invokers nickname to a spooky one!"""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @commands.cooldown(1, 300, BucketType.user)
+ @commands.command()
+ async def halloweenify(self, ctx: commands.Context) -> None:
+ """Change your nickname into a much spookier one!"""
+ async with ctx.typing():
+ with open(Path("bot/resources/halloween/halloweenify.json"), "r") as f:
+ data = load(f)
+
+ # Choose a random character from our list we loaded above and set apart the nickname and image url.
+ character = choice(data["characters"])
+ nickname = ''.join([nickname for nickname in character])
+ image = ''.join([character[nickname] for nickname in character])
+
+ # Build up a Embed
+ embed = discord.Embed()
+ embed.colour = discord.Colour.dark_orange()
+ embed.title = "Not spooky enough?"
+ embed.description = (
+ f"**{ctx.author.display_name}** wasn\'t spooky enough for you? That\'s understandable, "
+ f"{ctx.author.display_name} isn\'t scary at all! "
+ "Let me think of something better. Hmm... I got it!\n\n "
+ f"Your new nickname will be: \n :ghost: **{nickname}** :jack_o_lantern:"
+ )
+ embed.set_image(url=image)
+
+ await ctx.author.edit(nick=nickname)
+
+ await ctx.send(embed=embed)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Halloweenify Cog load."""
+ bot.add_cog(Halloweenify(bot))
diff --git a/bot/exts/halloween/monsterbio.py b/bot/exts/halloween/monsterbio.py
new file mode 100644
index 00000000..016a66d1
--- /dev/null
+++ b/bot/exts/halloween/monsterbio.py
@@ -0,0 +1,55 @@
+import json
+import logging
+import random
+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/halloween/monster.json"), "r", encoding="utf8") as f:
+ TEXT_OPTIONS = json.load(f) # Data for a mad-lib style generation of text
+
+
+class MonsterBio(commands.Cog):
+ """A cog that generates a spooky monster biography."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ def generate_name(self, seeded_random: random.Random) -> str:
+ """Generates a name (for either monster species or monster name)."""
+ n_candidate_strings = seeded_random.randint(2, len(TEXT_OPTIONS["monster_type"]))
+ return "".join(seeded_random.choice(TEXT_OPTIONS["monster_type"][i]) for i in range(n_candidate_strings))
+
+ @commands.command(brief="Sends your monster bio!")
+ async def monsterbio(self, ctx: commands.Context) -> None:
+ """Sends a description of a monster."""
+ seeded_random = random.Random(ctx.message.author.id) # Seed a local Random instance rather than the system one
+
+ name = self.generate_name(seeded_random)
+ species = self.generate_name(seeded_random)
+ biography_text = seeded_random.choice(TEXT_OPTIONS["biography_text"])
+ words = {"monster_name": name, "monster_species": species}
+ for key, value in biography_text.items():
+ if key == "text":
+ continue
+
+ options = seeded_random.sample(TEXT_OPTIONS[key], value)
+ words[key] = ' '.join(options)
+
+ embed = discord.Embed(
+ title=f"{name}'s Biography",
+ color=seeded_random.choice([Colours.orange, Colours.purple]),
+ description=biography_text["text"].format_map(words),
+ )
+
+ await ctx.send(embed=embed)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Monster bio Cog load."""
+ bot.add_cog(MonsterBio(bot))
diff --git a/bot/exts/halloween/monstersurvey.py b/bot/exts/halloween/monstersurvey.py
new file mode 100644
index 00000000..27da79b6
--- /dev/null
+++ b/bot/exts/halloween/monstersurvey.py
@@ -0,0 +1,205 @@
+import json
+import logging
+import os
+
+from discord import Embed
+from discord.ext import commands
+from discord.ext.commands import Bot, Cog, Context
+
+log = logging.getLogger(__name__)
+
+EMOJIS = {
+ 'SUCCESS': u'\u2705',
+ 'ERROR': u'\u274C'
+}
+
+
+class MonsterSurvey(Cog):
+ """
+ Vote for your favorite monster.
+
+ This Cog allows users to vote for their favorite listed monster.
+
+ Users may change their vote, but only their current vote will be counted.
+ """
+
+ def __init__(self, bot: Bot):
+ """Initializes values for the bot to use within the voting commands."""
+ self.bot = bot
+ self.registry_location = os.path.join(os.getcwd(), 'bot', 'resources', 'halloween', 'monstersurvey.json')
+ with open(self.registry_location, 'r') as jason:
+ self.voter_registry = json.load(jason)
+
+ def json_write(self) -> None:
+ """Write voting results to a local JSON file."""
+ log.info("Saved Monster Survey Results")
+ with open(self.registry_location, 'w') as jason:
+ json.dump(self.voter_registry, jason, indent=2)
+
+ def cast_vote(self, id: int, monster: str) -> None:
+ """
+ Cast a user's vote for the specified monster.
+
+ If the user has already voted, their existing vote is removed.
+ """
+ vr = self.voter_registry
+ for m in vr.keys():
+ if id not in vr[m]['votes'] and m == monster:
+ vr[m]['votes'].append(id)
+ else:
+ if id in vr[m]['votes'] and m != monster:
+ vr[m]['votes'].remove(id)
+
+ def get_name_by_leaderboard_index(self, n: int) -> str:
+ """Return the monster at the specified leaderboard index."""
+ n = n - 1
+ vr = self.voter_registry
+ top = sorted(vr, key=lambda k: len(vr[k]['votes']), reverse=True)
+ name = top[n] if n >= 0 else None
+ return name
+
+ @commands.group(
+ name='monster',
+ aliases=('mon',)
+ )
+ async def monster_group(self, ctx: Context) -> None:
+ """The base voting command. If nothing is called, then it will return an embed."""
+ if ctx.invoked_subcommand is None:
+ async with ctx.typing():
+ default_embed = Embed(
+ title='Monster Voting',
+ color=0xFF6800,
+ description='Vote for your favorite monster!'
+ )
+ default_embed.add_field(
+ name='.monster show monster_name(optional)',
+ value='Show a specific monster. If none is listed, it will give you an error with valid choices.',
+ inline=False)
+ default_embed.add_field(
+ name='.monster vote monster_name',
+ value='Vote for a specific monster. You get one vote, but can change it at any time.',
+ inline=False
+ )
+ default_embed.add_field(
+ name='.monster leaderboard',
+ value='Which monster has the most votes? This command will tell you.',
+ inline=False
+ )
+ default_embed.set_footer(text=f"Monsters choices are: {', '.join(self.voter_registry.keys())}")
+
+ await ctx.send(embed=default_embed)
+
+ @monster_group.command(
+ name='vote'
+ )
+ async def monster_vote(self, ctx: Context, name: str = None) -> None:
+ """
+ Cast a vote for a particular monster.
+
+ Displays a list of monsters that can be voted for if one is not specified.
+ """
+ if name is None:
+ await ctx.invoke(self.monster_leaderboard)
+ return
+
+ async with ctx.typing():
+ # Check to see if user used a numeric (leaderboard) index to vote
+ try:
+ idx = int(name)
+ name = self.get_name_by_leaderboard_index(idx)
+ except ValueError:
+ name = name.lower()
+
+ vote_embed = Embed(
+ name='Monster Voting',
+ color=0xFF6800
+ )
+
+ m = self.voter_registry.get(name)
+ if m is None:
+ vote_embed.description = f'You cannot vote for {name} because it\'s not in the running.'
+ vote_embed.add_field(
+ name='Use `.monster show {monster_name}` for more information on a specific monster',
+ value='or use `.monster vote {monster}` to cast your vote for said monster.',
+ inline=False
+ )
+ vote_embed.add_field(
+ name='You may vote for or show the following monsters:',
+ value=f"{', '.join(self.voter_registry.keys())}"
+ )
+ else:
+ self.cast_vote(ctx.author.id, name)
+ vote_embed.add_field(
+ name='Vote successful!',
+ value=f'You have successfully voted for {m["full_name"]}!',
+ inline=False
+ )
+ vote_embed.set_thumbnail(url=m['image'])
+ vote_embed.set_footer(text="Please note that any previous votes have been removed.")
+ self.json_write()
+
+ await ctx.send(embed=vote_embed)
+
+ @monster_group.command(
+ name='show'
+ )
+ async def monster_show(self, ctx: Context, name: str = None) -> None:
+ """Shows the named monster. If one is not named, it sends the default voting embed instead."""
+ if name is None:
+ await ctx.invoke(self.monster_leaderboard)
+ return
+
+ async with ctx.typing():
+ # Check to see if user used a numeric (leaderboard) index to vote
+ try:
+ idx = int(name)
+ name = self.get_name_by_leaderboard_index(idx)
+ except ValueError:
+ name = name.lower()
+
+ m = self.voter_registry.get(name)
+ if not m:
+ await ctx.send('That monster does not exist.')
+ await ctx.invoke(self.monster_vote)
+ return
+
+ embed = Embed(title=m['full_name'], color=0xFF6800)
+ embed.add_field(name='Summary', value=m['summary'])
+ embed.set_image(url=m['image'])
+ embed.set_footer(text=f'To vote for this monster, type .monster vote {name}')
+
+ await ctx.send(embed=embed)
+
+ @monster_group.command(
+ name='leaderboard',
+ aliases=('lb',)
+ )
+ async def monster_leaderboard(self, ctx: Context) -> None:
+ """Shows the current standings."""
+ async with ctx.typing():
+ vr = self.voter_registry
+ top = sorted(vr, key=lambda k: len(vr[k]['votes']), reverse=True)
+ total_votes = sum(len(m['votes']) for m in self.voter_registry.values())
+
+ embed = Embed(title="Monster Survey Leader Board", color=0xFF6800)
+ for rank, m in enumerate(top):
+ votes = len(vr[m]['votes'])
+ percentage = ((votes / total_votes) * 100) if total_votes > 0 else 0
+ embed.add_field(name=f"{rank+1}. {vr[m]['full_name']}",
+ value=(
+ f"{votes} votes. {percentage:.1f}% of total votes.\n"
+ f"Vote for this monster by typing "
+ f"'.monster vote {m}'\n"
+ f"Get more information on this monster by typing "
+ f"'.monster show {m}'"
+ ),
+ inline=False)
+
+ embed.set_footer(text="You can also vote by their rank number. '.monster vote {number}' ")
+
+ await ctx.send(embed=embed)
+
+
+def setup(bot: Bot) -> None:
+ """Monster survey Cog load."""
+ bot.add_cog(MonsterSurvey(bot))
diff --git a/bot/exts/halloween/scarymovie.py b/bot/exts/halloween/scarymovie.py
new file mode 100644
index 00000000..c80e0298
--- /dev/null
+++ b/bot/exts/halloween/scarymovie.py
@@ -0,0 +1,131 @@
+import logging
+import random
+from os import environ
+
+import aiohttp
+from discord import Embed
+from discord.ext import commands
+
+log = logging.getLogger(__name__)
+
+
+TMDB_API_KEY = environ.get('TMDB_API_KEY')
+TMDB_TOKEN = environ.get('TMDB_TOKEN')
+
+
+class ScaryMovie(commands.Cog):
+ """Selects a random scary movie and embeds info into Discord chat."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @commands.command(name='scarymovie', alias=['smovie'])
+ async def random_movie(self, ctx: commands.Context) -> None:
+ """Randomly select a scary movie and display information about it."""
+ async with ctx.typing():
+ selection = await self.select_movie()
+ movie_details = await self.format_metadata(selection)
+
+ await ctx.send(embed=movie_details)
+
+ @staticmethod
+ async def select_movie() -> dict:
+ """Selects a random movie and returns a JSON of movie details from TMDb."""
+ url = 'https://api.themoviedb.org/4/discover/movie'
+ params = {
+ 'with_genres': '27',
+ 'vote_count.gte': '5'
+ }
+ headers = {
+ 'Authorization': 'Bearer ' + TMDB_TOKEN,
+ 'Content-Type': 'application/json;charset=utf-8'
+ }
+
+ # Get total page count of horror movies
+ async with aiohttp.ClientSession() as session:
+ response = await session.get(url=url, params=params, headers=headers)
+ total_pages = await response.json()
+ total_pages = total_pages.get('total_pages')
+
+ # Get movie details from one random result on a random page
+ params['page'] = random.randint(1, total_pages)
+ response = await session.get(url=url, params=params, headers=headers)
+ response = await response.json()
+ selection_id = random.choice(response.get('results')).get('id')
+
+ # Get full details and credits
+ selection = await session.get(
+ url='https://api.themoviedb.org/3/movie/' + str(selection_id),
+ params={'api_key': TMDB_API_KEY, 'append_to_response': 'credits'}
+ )
+
+ return await selection.json()
+
+ @staticmethod
+ async def format_metadata(movie: dict) -> Embed:
+ """Formats raw TMDb data to be embedded in Discord chat."""
+ # Build the relevant URLs.
+ movie_id = movie.get("id")
+ poster_path = movie.get("poster_path")
+ tmdb_url = f'https://www.themoviedb.org/movie/{movie_id}' if movie_id else None
+ poster = f'https://image.tmdb.org/t/p/original{poster_path}' if poster_path else None
+
+ # Get cast names
+ cast = []
+ for actor in movie.get('credits', {}).get('cast', [])[:3]:
+ cast.append(actor.get('name'))
+
+ # Get director name
+ director = movie.get('credits', {}).get('crew', [])
+ if director:
+ director = director[0].get('name')
+
+ # Determine the spookiness rating
+ rating = ''
+ rating_count = movie.get('vote_average', 0)
+
+ if rating_count:
+ rating_count /= 2
+
+ for _ in range(int(rating_count)):
+ rating += ':skull:'
+ if (rating_count % 1) >= .5:
+ rating += ':bat:'
+
+ # Try to get year of release and runtime
+ year = movie.get('release_date', [])[:4]
+ runtime = movie.get('runtime')
+ runtime = f"{runtime} minutes" if runtime else None
+
+ # Not all these attributes will always be present
+ movie_attributes = {
+ "Directed by": director,
+ "Starring": ', '.join(cast),
+ "Running time": runtime,
+ "Release year": year,
+ "Spookiness rating": rating,
+ }
+
+ embed = Embed(
+ colour=0x01d277,
+ title='**' + movie.get('title') + '**',
+ url=tmdb_url,
+ description=movie.get('overview')
+ )
+
+ if poster:
+ embed.set_image(url=poster)
+
+ # Add the attributes that we actually have data for, but not the others.
+ for name, value in movie_attributes.items():
+ if value:
+ embed.add_field(name=name, value=value)
+
+ embed.set_footer(text='powered by themoviedb.org')
+
+ return embed
+
+
+def setup(bot: commands.Bot) -> None:
+ """Scary movie Cog load."""
+ bot.add_cog(ScaryMovie(bot))
diff --git a/bot/exts/halloween/spookyavatar.py b/bot/exts/halloween/spookyavatar.py
new file mode 100644
index 00000000..2d7df678
--- /dev/null
+++ b/bot/exts/halloween/spookyavatar.py
@@ -0,0 +1,52 @@
+import logging
+import os
+from io import BytesIO
+
+import aiohttp
+import discord
+from PIL import Image
+from discord.ext import commands
+
+from bot.utils.halloween import spookifications
+
+log = logging.getLogger(__name__)
+
+
+class SpookyAvatar(commands.Cog):
+ """A cog that spookifies an avatar."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ async def get(self, url: str) -> bytes:
+ """Returns the contents of the supplied URL."""
+ async with aiohttp.ClientSession() as session:
+ async with session.get(url) as resp:
+ return await resp.read()
+
+ @commands.command(name='savatar', aliases=('spookyavatar', 'spookify'),
+ brief='Spookify an user\'s avatar.')
+ async def spooky_avatar(self, ctx: commands.Context, user: discord.Member = None) -> None:
+ """A command to print the user's spookified avatar."""
+ if user is None:
+ user = ctx.message.author
+
+ async with ctx.typing():
+ embed = discord.Embed(colour=0xFF0000)
+ embed.title = "Is this you or am I just really paranoid?"
+ embed.set_author(name=str(user.name), icon_url=user.avatar_url)
+
+ image_bytes = await ctx.author.avatar_url.read()
+ im = Image.open(BytesIO(image_bytes))
+ modified_im = spookifications.get_random_effect(im)
+ modified_im.save(str(ctx.message.id)+'.png')
+ f = discord.File(str(ctx.message.id)+'.png')
+ embed.set_image(url='attachment://'+str(ctx.message.id)+'.png')
+
+ await ctx.send(file=f, embed=embed)
+ os.remove(str(ctx.message.id)+'.png')
+
+
+def setup(bot: commands.Bot) -> None:
+ """Spooky avatar Cog load."""
+ bot.add_cog(SpookyAvatar(bot))
diff --git a/bot/exts/halloween/spookygif.py b/bot/exts/halloween/spookygif.py
new file mode 100644
index 00000000..f402437f
--- /dev/null
+++ b/bot/exts/halloween/spookygif.py
@@ -0,0 +1,38 @@
+import logging
+
+import aiohttp
+import discord
+from discord.ext import commands
+
+from bot.constants import Tokens
+
+log = logging.getLogger(__name__)
+
+
+class SpookyGif(commands.Cog):
+ """A cog to fetch a random spooky gif from the web!"""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @commands.command(name="spookygif", aliases=("sgif", "scarygif"))
+ async def spookygif(self, ctx: commands.Context) -> None:
+ """Fetches a random gif from the GIPHY API and responds with it."""
+ async with ctx.typing():
+ async with aiohttp.ClientSession() as session:
+ params = {'api_key': Tokens.giphy, 'tag': 'halloween', 'rating': 'g'}
+ # Make a GET request to the Giphy API to get a random halloween gif.
+ async with session.get('http://api.giphy.com/v1/gifs/random', params=params) as resp:
+ data = await resp.json()
+ url = data['data']['image_url']
+
+ embed = discord.Embed(colour=0x9b59b6)
+ embed.title = "A spooooky gif!"
+ embed.set_image(url=url)
+
+ await ctx.send(embed=embed)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Spooky GIF Cog load."""
+ bot.add_cog(SpookyGif(bot))
diff --git a/bot/exts/halloween/spookyrating.py b/bot/exts/halloween/spookyrating.py
new file mode 100644
index 00000000..1a48194e
--- /dev/null
+++ b/bot/exts/halloween/spookyrating.py
@@ -0,0 +1,66 @@
+import bisect
+import json
+import logging
+import random
+from pathlib import Path
+
+import discord
+from discord.ext import commands
+
+from bot.constants import Colours
+
+log = logging.getLogger(__name__)
+
+with Path("bot/resources/halloween/spooky_rating.json").open() as file:
+ SPOOKY_DATA = json.load(file)
+ SPOOKY_DATA = sorted((int(key), value) for key, value in SPOOKY_DATA.items())
+
+
+class SpookyRating(commands.Cog):
+ """A cog for calculating one's spooky rating."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+ self.local_random = random.Random()
+
+ @commands.command()
+ @commands.cooldown(rate=1, per=5, type=commands.BucketType.user)
+ async def spookyrating(self, ctx: commands.Context, who: discord.Member = None) -> None:
+ """
+ Calculates the spooky rating of someone.
+
+ Any user will always yield the same result, no matter who calls the command
+ """
+ if who is None:
+ who = ctx.author
+
+ # This ensures that the same result over multiple runtimes
+ self.local_random.seed(who.id)
+ spooky_percent = self.local_random.randint(1, 101)
+
+ # We need the -1 due to how bisect returns the point
+ # see the documentation for further detail
+ # https://docs.python.org/3/library/bisect.html#bisect.bisect
+ index = bisect.bisect(SPOOKY_DATA, (spooky_percent,)) - 1
+
+ _, data = SPOOKY_DATA[index]
+
+ embed = discord.Embed(
+ title=data['title'],
+ description=f'{who} scored {spooky_percent}%!',
+ color=Colours.orange
+ )
+ embed.add_field(
+ name='A whisper from Satan',
+ value=data['text']
+ )
+ embed.set_thumbnail(
+ url=data['image']
+ )
+
+ await ctx.send(embed=embed)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Spooky Rating Cog load."""
+ bot.add_cog(SpookyRating(bot))
diff --git a/bot/exts/halloween/spookyreact.py b/bot/exts/halloween/spookyreact.py
new file mode 100644
index 00000000..e5945aea
--- /dev/null
+++ b/bot/exts/halloween/spookyreact.py
@@ -0,0 +1,75 @@
+import logging
+import re
+
+import discord
+from discord.ext.commands import Bot, Cog
+
+from bot.constants import Month
+from bot.utils.decorators import in_month
+
+log = logging.getLogger(__name__)
+
+SPOOKY_TRIGGERS = {
+ 'spooky': (r"\bspo{2,}ky\b", "\U0001F47B"),
+ 'skeleton': (r"\bskeleton\b", "\U0001F480"),
+ 'doot': (r"\bdo{2,}t\b", "\U0001F480"),
+ 'pumpkin': (r"\bpumpkin\b", "\U0001F383"),
+ 'halloween': (r"\bhalloween\b", "\U0001F383"),
+ 'jack-o-lantern': (r"\bjack-o-lantern\b", "\U0001F383"),
+ 'danger': (r"\bdanger\b", "\U00002620")
+}
+
+
+class SpookyReact(Cog):
+ """A cog that makes the bot react to message triggers."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ @in_month(Month.OCTOBER)
+ @Cog.listener()
+ async def on_message(self, ctx: discord.Message) -> None:
+ """
+ A command to send the seasonalbot github project.
+
+ Lines that begin with the bot's command prefix are ignored
+
+ Seasonalbot's own messages are ignored
+ """
+ for trigger in SPOOKY_TRIGGERS.keys():
+ trigger_test = re.search(SPOOKY_TRIGGERS[trigger][0], ctx.content.lower())
+ if trigger_test:
+ # Check message for bot replies and/or command invocations
+ # Short circuit if they're found, logging is handled in _short_circuit_check
+ if await self._short_circuit_check(ctx):
+ return
+ else:
+ await ctx.add_reaction(SPOOKY_TRIGGERS[trigger][1])
+ logging.info(f"Added '{trigger}' reaction to message ID: {ctx.id}")
+
+ async def _short_circuit_check(self, ctx: discord.Message) -> bool:
+ """
+ Short-circuit helper check.
+
+ Return True if:
+ * author is the bot
+ * prefix is not None
+ """
+ # Check for self reaction
+ if ctx.author == self.bot.user:
+ logging.debug(f"Ignoring reactions on self message. Message ID: {ctx.id}")
+ return True
+
+ # Check for command invocation
+ # Because on_message doesn't give a full Context object, generate one first
+ tmp_ctx = await self.bot.get_context(ctx)
+ if tmp_ctx.prefix:
+ logging.debug(f"Ignoring reactions on command invocation. Message ID: {ctx.id}")
+ return True
+
+ return False
+
+
+def setup(bot: Bot) -> None:
+ """Spooky reaction Cog load."""
+ bot.add_cog(SpookyReact(bot))
diff --git a/bot/exts/halloween/spookysound.py b/bot/exts/halloween/spookysound.py
new file mode 100644
index 00000000..325447e5
--- /dev/null
+++ b/bot/exts/halloween/spookysound.py
@@ -0,0 +1,47 @@
+import logging
+import random
+from pathlib import Path
+
+import discord
+from discord.ext import commands
+
+from bot.constants import Hacktoberfest
+
+log = logging.getLogger(__name__)
+
+
+class SpookySound(commands.Cog):
+ """A cog that plays a spooky sound in a voice channel on command."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+ self.sound_files = list(Path("bot/resources/halloween/spookysounds").glob("*.mp3"))
+ self.channel = None
+
+ @commands.cooldown(rate=1, per=1)
+ @commands.command(brief="Play a spooky sound, restricted to once per 2 mins")
+ async def spookysound(self, ctx: commands.Context) -> None:
+ """
+ Connect to the Hacktoberbot voice channel, play a random spooky sound, then disconnect.
+
+ Cannot be used more than once in 2 minutes.
+ """
+ if not self.channel:
+ await self.bot.wait_until_ready()
+ self.channel = self.bot.get_channel(Hacktoberfest.voice_id)
+
+ await ctx.send("Initiating spooky sound...")
+ file_path = random.choice(self.sound_files)
+ src = discord.FFmpegPCMAudio(str(file_path.resolve()))
+ voice = await self.channel.connect()
+ voice.play(src, after=lambda e: self.bot.loop.create_task(self.disconnect(voice)))
+
+ @staticmethod
+ async def disconnect(voice: discord.VoiceClient) -> None:
+ """Helper method to disconnect a given voice client."""
+ await voice.disconnect()
+
+
+def setup(bot: commands.Bot) -> None:
+ """Spooky sound Cog load."""
+ bot.add_cog(SpookySound(bot))
diff --git a/bot/exts/halloween/timeleft.py b/bot/exts/halloween/timeleft.py
new file mode 100644
index 00000000..295acc89
--- /dev/null
+++ b/bot/exts/halloween/timeleft.py
@@ -0,0 +1,59 @@
+import logging
+from datetime import datetime
+from typing import Tuple
+
+from discord.ext import commands
+
+log = logging.getLogger(__name__)
+
+
+class TimeLeft(commands.Cog):
+ """A Cog that tells you how long left until Hacktober is over!"""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @staticmethod
+ def in_october() -> bool:
+ """Return True if the current month is October."""
+ return datetime.utcnow().month == 10
+
+ @staticmethod
+ def load_date() -> Tuple[int, datetime, datetime]:
+ """Return of a tuple of the current time and the end and start times of the next October."""
+ now = datetime.utcnow()
+ year = now.year
+ if now.month > 10:
+ year += 1
+ end = datetime(year, 11, 1, 11, 59, 59)
+ start = datetime(year, 10, 1)
+ return now, end, start
+
+ @commands.command()
+ async def timeleft(self, ctx: commands.Context) -> None:
+ """
+ Calculates the time left until the end of Hacktober.
+
+ Whilst in October, displays the days, hours and minutes left.
+ Only displays the days left until the beginning and end whilst in a different month
+ """
+ now, end, start = self.load_date()
+ diff = end - now
+ days, seconds = diff.days, diff.seconds
+ if self.in_october():
+ minutes = seconds // 60
+ hours, minutes = divmod(minutes, 60)
+ await ctx.send(f"There is currently only {days} days, {hours} hours and {minutes}"
+ "minutes left until the end of Hacktober.")
+ else:
+ start_diff = start - now
+ start_days = start_diff.days
+ await ctx.send(
+ f"It is not currently Hacktober. However, the next one will start in {start_days} days "
+ f"and will finish in {days} days."
+ )
+
+
+def setup(bot: commands.Bot) -> None:
+ """Cog load."""
+ bot.add_cog(TimeLeft(bot))
diff --git a/bot/exts/pride/__init__.py b/bot/exts/pride/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bot/exts/pride/__init__.py
diff --git a/bot/exts/pride/drag_queen_name.py b/bot/exts/pride/drag_queen_name.py
new file mode 100644
index 00000000..95297745
--- /dev/null
+++ b/bot/exts/pride/drag_queen_name.py
@@ -0,0 +1,32 @@
+import json
+import logging
+import random
+from pathlib import Path
+
+from discord.ext import commands
+
+log = logging.getLogger(__name__)
+
+
+class DragNames(commands.Cog):
+ """Gives a random drag queen name!"""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+ self.names = self.load_names()
+
+ @staticmethod
+ def load_names() -> list:
+ """Loads a list of drag queen names."""
+ with open(Path("bot/resources/pride/drag_queen_names.json"), "r", encoding="utf-8") as f:
+ return json.load(f)
+
+ @commands.command(name="dragname", aliases=["dragqueenname", "queenme"])
+ async def dragname(self, ctx: commands.Context) -> None:
+ """Sends a message with a drag queen name."""
+ await ctx.send(random.choice(self.names))
+
+
+def setup(bot: commands.Bot) -> None:
+ """Cog loader for drag queen name generator."""
+ bot.add_cog(DragNames(bot))
diff --git a/bot/exts/pride/pride_anthem.py b/bot/exts/pride/pride_anthem.py
new file mode 100644
index 00000000..186c5fff
--- /dev/null
+++ b/bot/exts/pride/pride_anthem.py
@@ -0,0 +1,57 @@
+import json
+import logging
+import random
+from pathlib import Path
+
+from discord.ext import commands
+
+log = logging.getLogger(__name__)
+
+
+class PrideAnthem(commands.Cog):
+ """Embed a random youtube video for a gay anthem!"""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+ self.anthems = self.load_vids()
+
+ def get_video(self, genre: str = None) -> dict:
+ """
+ Picks a random anthem from the list.
+
+ If `genre` is supplied, it will pick from videos attributed with that genre.
+ If none can be found, it will log this as well as provide that information to the user.
+ """
+ if not genre:
+ return random.choice(self.anthems)
+ else:
+ songs = [song for song in self.anthems if genre.casefold() in song["genre"]]
+ try:
+ return random.choice(songs)
+ except IndexError:
+ log.info("No videos for that genre.")
+
+ @staticmethod
+ def load_vids() -> list:
+ """Loads a list of videos from the resources folder as dictionaries."""
+ with open(Path("bot/resources/pride/anthems.json"), "r", encoding="utf-8") as f:
+ anthems = json.load(f)
+ return anthems
+
+ @commands.command(name="prideanthem", aliases=["anthem", "pridesong"])
+ async def prideanthem(self, ctx: commands.Context, genre: str = None) -> None:
+ """
+ Sends a message with a video of a random pride anthem.
+
+ If `genre` is supplied, it will select from that genre only.
+ """
+ anthem = self.get_video(genre)
+ if anthem:
+ await ctx.send(anthem["url"])
+ else:
+ await ctx.send("I couldn't find a video, sorry!")
+
+
+def setup(bot: commands.Bot) -> None:
+ """Cog loader for pride anthem."""
+ bot.add_cog(PrideAnthem(bot))
diff --git a/bot/exts/pride/pride_avatar.py b/bot/exts/pride/pride_avatar.py
new file mode 100644
index 00000000..3f9878e3
--- /dev/null
+++ b/bot/exts/pride/pride_avatar.py
@@ -0,0 +1,144 @@
+import logging
+from io import BytesIO
+from pathlib import Path
+
+import discord
+from PIL import Image, ImageDraw
+from discord.ext import commands
+
+from bot.constants import Colours
+
+log = logging.getLogger(__name__)
+
+OPTIONS = {
+ "agender": "agender",
+ "androgyne": "androgyne",
+ "androgynous": "androgyne",
+ "aromantic": "aromantic",
+ "aro": "aromantic",
+ "ace": "asexual",
+ "asexual": "asexual",
+ "bigender": "bigender",
+ "bisexual": "bisexual",
+ "bi": "bisexual",
+ "demiboy": "demiboy",
+ "demigirl": "demigirl",
+ "demi": "demisexual",
+ "demisexual": "demisexual",
+ "gay": "gay",
+ "lgbt": "gay",
+ "queer": "gay",
+ "homosexual": "gay",
+ "fluid": "genderfluid",
+ "genderfluid": "genderfluid",
+ "genderqueer": "genderqueer",
+ "intersex": "intersex",
+ "lesbian": "lesbian",
+ "non-binary": "nonbinary",
+ "enby": "nonbinary",
+ "nb": "nonbinary",
+ "nonbinary": "nonbinary",
+ "omnisexual": "omnisexual",
+ "omni": "omnisexual",
+ "pansexual": "pansexual",
+ "pan": "pansexual",
+ "pangender": "pangender",
+ "poly": "polysexual",
+ "polysexual": "polysexual",
+ "polyamory": "polyamory",
+ "polyamorous": "polyamory",
+ "transgender": "transgender",
+ "trans": "transgender",
+ "trigender": "trigender"
+}
+
+
+class PrideAvatar(commands.Cog):
+ """Put an LGBT spin on your avatar!"""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @staticmethod
+ def crop_avatar(avatar: Image) -> Image:
+ """This crops the avatar into a circle."""
+ mask = Image.new("L", avatar.size, 0)
+ draw = ImageDraw.Draw(mask)
+ draw.ellipse((0, 0) + avatar.size, fill=255)
+ avatar.putalpha(mask)
+ return avatar
+
+ @staticmethod
+ def crop_ring(ring: Image, px: int) -> Image:
+ """This crops the ring into a circle."""
+ mask = Image.new("L", ring.size, 0)
+ draw = ImageDraw.Draw(mask)
+ draw.ellipse((0, 0) + ring.size, fill=255)
+ draw.ellipse((px, px, 1024-px, 1024-px), fill=0)
+ ring.putalpha(mask)
+ return ring
+
+ @commands.group(aliases=["avatarpride", "pridepfp", "prideprofile"], invoke_without_command=True)
+ async def prideavatar(self, ctx: commands.Context, option: str = "lgbt", pixels: int = 64) -> None:
+ """
+ This surrounds an avatar with a border of a specified LGBT flag.
+
+ This defaults to the LGBT rainbow flag if none is given.
+ The amount of pixels can be given which determines the thickness of the flag border.
+ This has a maximum of 512px and defaults to a 64px border.
+ The full image is 1024x1024.
+ """
+ pixels = 0 if pixels < 0 else 512 if pixels > 512 else pixels
+
+ option = option.lower()
+
+ if option not in OPTIONS.keys():
+ return await ctx.send("I don't have that flag!")
+
+ flag = OPTIONS[option]
+
+ async with ctx.typing():
+
+ # Get avatar bytes
+ image_bytes = await ctx.author.avatar_url.read()
+ avatar = Image.open(BytesIO(image_bytes))
+ avatar = avatar.convert("RGBA").resize((1024, 1024))
+
+ avatar = self.crop_avatar(avatar)
+
+ ring = Image.open(Path(f"bot/resources/pride/flags/{flag}.png")).resize((1024, 1024))
+ ring = ring.convert("RGBA")
+ ring = self.crop_ring(ring, pixels)
+
+ avatar.alpha_composite(ring, (0, 0))
+ bufferedio = BytesIO()
+ avatar.save(bufferedio, format="PNG")
+ bufferedio.seek(0)
+
+ file = discord.File(bufferedio, filename="pride_avatar.png") # Creates file to be used in embed
+ embed = discord.Embed(
+ name="Your Lovely Pride Avatar",
+ description=f"Here is your lovely avatar, surrounded by\n a beautiful {option} flag. Enjoy :D"
+ )
+ embed.set_image(url="attachment://pride_avatar.png")
+ embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url)
+
+ await ctx.send(file=file, embed=embed)
+
+ @prideavatar.command()
+ async def flags(self, ctx: commands.Context) -> None:
+ """This lists the flags that can be used with the prideavatar command."""
+ choices = sorted(set(OPTIONS.values()))
+ options = "• " + "\n• ".join(choices)
+ embed = discord.Embed(
+ title="I have the following flags:",
+ description=options,
+ colour=Colours.soft_red
+ )
+
+ await ctx.send(embed=embed)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Cog load."""
+ bot.add_cog(PrideAvatar(bot))
diff --git a/bot/exts/pride/pride_facts.py b/bot/exts/pride/pride_facts.py
new file mode 100644
index 00000000..f759dcb1
--- /dev/null
+++ b/bot/exts/pride/pride_facts.py
@@ -0,0 +1,106 @@
+import json
+import logging
+import random
+from datetime import datetime
+from pathlib import Path
+from typing import Union
+
+import dateutil.parser
+import discord
+from discord.ext import commands
+
+from bot.constants import Channels, Colours, Month
+from bot.utils.decorators import seasonal_task
+
+log = logging.getLogger(__name__)
+
+Sendable = Union[commands.Context, discord.TextChannel]
+
+
+class PrideFacts(commands.Cog):
+ """Provides a new fact every day during the Pride season!"""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+ self.facts = self.load_facts()
+
+ self.daily_fact_task = self.bot.loop.create_task(self.send_pride_fact_daily())
+
+ @staticmethod
+ def load_facts() -> dict:
+ """Loads a dictionary of years mapping to lists of facts."""
+ with open(Path("bot/resources/pride/facts.json"), "r", encoding="utf-8") as f:
+ return json.load(f)
+
+ @seasonal_task(Month.JUNE)
+ async def send_pride_fact_daily(self) -> None:
+ """Background task to post the daily pride fact every day."""
+ await self.bot.wait_until_ready()
+
+ channel = self.bot.get_channel(Channels.seasonalbot_commands)
+ await self.send_select_fact(channel, datetime.utcnow())
+
+ async def send_random_fact(self, ctx: commands.Context) -> None:
+ """Provides a fact from any previous day, or today."""
+ now = datetime.utcnow()
+ previous_years_facts = (self.facts[x] for x in self.facts.keys() if int(x) < now.year)
+ current_year_facts = self.facts.get(str(now.year), [])[:now.day]
+ previous_facts = current_year_facts + [x for y in previous_years_facts for x in y]
+ try:
+ await ctx.send(embed=self.make_embed(random.choice(previous_facts)))
+ except IndexError:
+ await ctx.send("No facts available")
+
+ async def send_select_fact(self, target: Sendable, _date: Union[str, datetime]) -> None:
+ """Provides the fact for the specified day, if the day is today, or is in the past."""
+ now = datetime.utcnow()
+ if isinstance(_date, str):
+ try:
+ date = dateutil.parser.parse(_date, dayfirst=False, yearfirst=False, fuzzy=True)
+ except (ValueError, OverflowError) as err:
+ await target.send(f"Error parsing date: {err}")
+ return
+ else:
+ date = _date
+ if date.year < now.year or (date.year == now.year and date.day <= now.day):
+ try:
+ await target.send(embed=self.make_embed(self.facts[str(date.year)][date.day - 1]))
+ except KeyError:
+ await target.send(f"The year {date.year} is not yet supported")
+ return
+ except IndexError:
+ await target.send(f"Day {date.day} of {date.year} is not yet support")
+ return
+ else:
+ await target.send("The fact for the selected day is not yet available.")
+
+ @commands.command(name="pridefact", aliases=["pridefacts"])
+ async def pridefact(self, ctx: commands.Context) -> None:
+ """
+ Sends a message with a pride fact of the day.
+
+ If "random" is given as an argument, a random previous fact will be provided.
+
+ If a date is given as an argument, and the date is in the past, the fact from that day
+ will be provided.
+ """
+ message_body = ctx.message.content[len(ctx.invoked_with) + 2:]
+ if message_body == "":
+ await self.send_select_fact(ctx, datetime.utcnow())
+ elif message_body.lower().startswith("rand"):
+ await self.send_random_fact(ctx)
+ else:
+ await self.send_select_fact(ctx, message_body)
+
+ def make_embed(self, fact: str) -> discord.Embed:
+ """Makes a nice embed for the fact to be sent."""
+ return discord.Embed(
+ colour=Colours.pink,
+ title="Pride Fact!",
+ description=fact
+ )
+
+
+def setup(bot: commands.Bot) -> None:
+ """Cog loader for pride facts."""
+ bot.add_cog(PrideFacts(bot))
diff --git a/bot/exts/valentines/__init__.py b/bot/exts/valentines/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bot/exts/valentines/__init__.py
diff --git a/bot/exts/valentines/be_my_valentine.py b/bot/exts/valentines/be_my_valentine.py
new file mode 100644
index 00000000..e5e71d25
--- /dev/null
+++ b/bot/exts/valentines/be_my_valentine.py
@@ -0,0 +1,236 @@
+import logging
+import random
+from json import load
+from pathlib import Path
+from typing import Optional, Tuple
+
+import discord
+from discord.ext import commands
+from discord.ext.commands.cooldowns import BucketType
+
+from bot.constants import Channels, Client, Colours, Lovefest, Month
+from bot.utils.decorators import in_month
+
+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: commands.Bot):
+ self.bot = bot
+ self.valentines = self.load_json()
+
+ @staticmethod
+ def load_json() -> dict:
+ """Load Valentines messages from the static resources."""
+ p = Path("bot/resources/valentines/bemyvalentine_valentines.json")
+ with p.open() as json_data:
+ valentines = load(json_data)
+ return valentines
+
+ @in_month(Month.FEBRUARY)
+ @commands.group(name="lovefest")
+ async def lovefest_role(self, ctx: commands.Context) -> None:
+ """
+ Subscribe or unsubscribe from the lovefest role.
+
+ The lovefest role makes you eligible to receive anonymous valentines from other users.
+
+ 1) use the command \".lovefest sub\" to get the lovefest role.
+ 2) use the command \".lovefest unsub\" to get rid of the lovefest role.
+ """
+ if not ctx.invoked_subcommand:
+ await ctx.send_help(ctx.command)
+
+ @lovefest_role.command(name="sub")
+ async def add_role(self, ctx: commands.Context) -> None:
+ """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: commands.Context) -> None:
+ """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: commands.Context, user: Optional[discord.Member] = None, *, valentine_type: str = None
+ ) -> None:
+ """
+ Send a valentine to user, if specified, or to a random user with the 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(Channels.seasonalbot_commands)
+ 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: commands.Context, user: Optional[discord.Member] = None, *, valentine_type: str = None
+ ) -> None:
+ """
+ Send an anonymous Valentine via DM to to a user, if specified, or to a random with the 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: str) -> Tuple[str, str]:
+ """Return the appropriate Valentine type & title based on the invoking user's input."""
+ 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: discord.Member, members: discord.Member) -> None:
+ """
+ Picks a random member from the list provided in `members`.
+
+ The invoking author is ignored.
+ """
+ if author in members:
+ members.remove(author)
+
+ return random.choice(members) if members else None
+
+ @staticmethod
+ def random_emoji() -> Tuple[str, str]:
+ """Return two random emoji from the module-defined constants."""
+ emoji_1 = random.choice(HEART_EMOJIS)
+ emoji_2 = random.choice(HEART_EMOJIS)
+ return emoji_1, emoji_2
+
+ def random_valentine(self) -> Tuple[str, str]:
+ """Grabs a random poem or a compliment (any message)."""
+ valentine_poem = random.choice(self.valentines['valentine_poems'])
+ valentine_compliment = random.choice(self.valentines['valentine_compliments'])
+ random_valentine = random.choice([valentine_compliment, valentine_poem])
+ if random_valentine == valentine_poem:
+ title = 'A poem dedicated to'
+ else:
+ title = 'A compliment for '
+ return random_valentine, title
+
+ def valentine_poem(self) -> str:
+ """Grabs a random poem."""
+ valentine_poem = random.choice(self.valentines['valentine_poems'])
+ return valentine_poem
+
+ def valentine_compliment(self) -> str:
+ """Grabs a random compliment."""
+ valentine_compliment = random.choice(self.valentines['valentine_compliments'])
+ return valentine_compliment
+
+
+def setup(bot: commands.Bot) -> None:
+ """Be my Valentine Cog load."""
+ bot.add_cog(BeMyValentine(bot))
diff --git a/bot/exts/valentines/lovecalculator.py b/bot/exts/valentines/lovecalculator.py
new file mode 100644
index 00000000..e11e062b
--- /dev/null
+++ b/bot/exts/valentines/lovecalculator.py
@@ -0,0 +1,103 @@
+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: commands.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: commands.Context, who: Union[Member, str], whom: Union[Member, str] = None) -> 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: Union[Member, str]) -> str:
+ 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() 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: commands.Bot) -> None:
+ """Love calculator Cog load."""
+ bot.add_cog(LoveCalculator(bot))
diff --git a/bot/exts/valentines/movie_generator.py b/bot/exts/valentines/movie_generator.py
new file mode 100644
index 00000000..0843175a
--- /dev/null
+++ b/bot/exts/valentines/movie_generator.py
@@ -0,0 +1,62 @@
+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: commands.Bot):
+ self.bot = bot
+
+ @commands.command(name="romancemovie")
+ async def romance_movie(self, ctx: commands.Context) -> None:
+ """Randomly selects a romance movie and displays information about it."""
+ # Selecting a random int to parse it to the page parameter
+ random_page = random.randint(0, 20)
+ # TMDB api params
+ params = {
+ "api_key": TMDB_API_KEY,
+ "language": "en-US",
+ "sort_by": "popularity.desc",
+ "include_adult": "false",
+ "include_video": "false",
+ "page": random_page,
+ "with_genres": "10749"
+ }
+ # The api request url
+ request_url = "https://api.themoviedb.org/3/discover/movie?" + 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: commands.Bot) -> None:
+ """Romance movie Cog load."""
+ bot.add_cog(RomanceMovieFinder(bot))
diff --git a/bot/exts/valentines/myvalenstate.py b/bot/exts/valentines/myvalenstate.py
new file mode 100644
index 00000000..7d8737c4
--- /dev/null
+++ b/bot/exts/valentines/myvalenstate.py
@@ -0,0 +1,86 @@
+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):
+ """A Cog to find your most likely Valentine's vacation destination."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ def levenshtein(self, source: str, goal: str) -> int:
+ """Calculates the Levenshtein Distance between source and goal."""
+ if len(source) < len(goal):
+ return self.levenshtein(goal, source)
+ if len(source) == 0:
+ return len(goal)
+ if len(goal) == 0:
+ return len(source)
+
+ pre_row = list(range(0, len(source) + 1))
+ for i, source_c in enumerate(source):
+ cur_row = [i + 1]
+ for j, goal_c in enumerate(goal):
+ if source_c != goal_c:
+ cur_row.append(min(pre_row[j], pre_row[j + 1], cur_row[j]) + 1)
+ else:
+ cur_row.append(min(pre_row[j], pre_row[j + 1], cur_row[j]))
+ pre_row = cur_row
+ return pre_row[-1]
+
+ @commands.command()
+ async def myvalenstate(self, ctx: commands.Context, *, name: str = None) -> None:
+ """Find the vacation spot(s) with the most matching characters to the invoking user."""
+ eq_chars = collections.defaultdict(int)
+ if name is None:
+ author = ctx.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: commands.Bot) -> None:
+ """Valenstate Cog load."""
+ bot.add_cog(MyValenstate(bot))
diff --git a/bot/exts/valentines/pickuplines.py b/bot/exts/valentines/pickuplines.py
new file mode 100644
index 00000000..74c7e68b
--- /dev/null
+++ b/bot/exts/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: commands.Bot):
+ self.bot = bot
+
+ @commands.command()
+ async def pickupline(self, ctx: commands.Context) -> None:
+ """
+ Gives you a random pickup line.
+
+ Note that most of them are very cheesy.
+ """
+ random_line = random.choice(pickup_lines['lines'])
+ embed = discord.Embed(
+ title=':cheese: Your pickup line :cheese:',
+ description=random_line['line'],
+ color=Colours.pink
+ )
+ embed.set_thumbnail(
+ url=random_line.get('image', pickup_lines['placeholder'])
+ )
+ await ctx.send(embed=embed)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Pickup lines Cog load."""
+ bot.add_cog(PickupLine(bot))
diff --git a/bot/exts/valentines/savethedate.py b/bot/exts/valentines/savethedate.py
new file mode 100644
index 00000000..ac38d279
--- /dev/null
+++ b/bot/exts/valentines/savethedate.py
@@ -0,0 +1,41 @@
+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 Valentine's date."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @commands.command()
+ async def savethedate(self, ctx: commands.Context) -> None:
+ """Gives you ideas for what to do on a date with your valentine."""
+ random_date = random.choice(VALENTINES_DATES['ideas'])
+ emoji_1 = random.choice(HEART_EMOJIS)
+ emoji_2 = random.choice(HEART_EMOJIS)
+ embed = discord.Embed(
+ title=f"{emoji_1}{random_date['name']}{emoji_2}",
+ description=f"{random_date['description']}",
+ colour=Colours.pink
+ )
+ await ctx.send(embed=embed)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Save the date Cog Load."""
+ bot.add_cog(SaveTheDate(bot))
diff --git a/bot/exts/valentines/valentine_zodiac.py b/bot/exts/valentines/valentine_zodiac.py
new file mode 100644
index 00000000..1a1273aa
--- /dev/null
+++ b/bot/exts/valentines/valentine_zodiac.py
@@ -0,0 +1,57 @@
+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: commands.Bot):
+ self.bot = bot
+ self.zodiacs = self.load_json()
+
+ @staticmethod
+ def load_json() -> dict:
+ """Load zodiac compatibility from static JSON resource."""
+ 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: commands.Context, zodiac_sign: str) -> None:
+ """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: commands.Bot) -> None:
+ """Valentine zodiac Cog load."""
+ bot.add_cog(ValentineZodiac(bot))
diff --git a/bot/exts/valentines/whoisvalentine.py b/bot/exts/valentines/whoisvalentine.py
new file mode 100644
index 00000000..4ca0289c
--- /dev/null
+++ b/bot/exts/valentines/whoisvalentine.py
@@ -0,0 +1,52 @@
+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):
+ """A Cog for displaying facts about Saint Valentine."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @commands.command(aliases=('whoisvalentine', 'saint_valentine'))
+ async def who_is_valentine(self, ctx: commands.Context) -> None:
+ """Displays info about Saint Valentine."""
+ embed = discord.Embed(
+ title="Who is Saint Valentine?",
+ description=FACTS['whois'],
+ color=Colours.pink
+ )
+ embed.set_thumbnail(
+ url='https://upload.wikimedia.org/wikipedia/commons/thumb/f/f1/Saint_Valentine_-_'
+ 'facial_reconstruction.jpg/1024px-Saint_Valentine_-_facial_reconstruction.jpg'
+ )
+
+ await ctx.channel.send(embed=embed)
+
+ @commands.command()
+ async def valentine_fact(self, ctx: commands.Context) -> None:
+ """Shows a random fact about Valentine's Day."""
+ embed = discord.Embed(
+ title=choice(FACTS['titles']),
+ description=choice(FACTS['text']),
+ color=Colours.pink
+ )
+
+ await ctx.channel.send(embed=embed)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Who is Valentine Cog load."""
+ bot.add_cog(ValentineFacts(bot))