From 7d5b420662496e1eeadf8bf10e4da3994591bdd9 Mon Sep 17 00:00:00 2001 From: Janine vN Date: Sat, 4 Sep 2021 22:54:16 -0400 Subject: Move AoC and Hacktoberfest into events folder Moves Advent of Code and Hacktoberfest into an events folder. Although these are roughly associated with holidays, they are standalone events that we have participated in in the past. Therefore they're being moved to an events folder separate from the "fun" or "holidays" folders. --- bot/exts/christmas/__init__.py | 0 bot/exts/christmas/advent_of_code/__init__.py | 10 - bot/exts/christmas/advent_of_code/_caches.py | 5 - bot/exts/christmas/advent_of_code/_cog.py | 302 ----------- bot/exts/christmas/advent_of_code/_helpers.py | 591 --------------------- bot/exts/events/advent_of_code/__init__.py | 10 + bot/exts/events/advent_of_code/_caches.py | 5 + bot/exts/events/advent_of_code/_cog.py | 302 +++++++++++ bot/exts/events/advent_of_code/_helpers.py | 591 +++++++++++++++++++++ bot/exts/events/hacktoberfest/__init__.py | 0 .../events/hacktoberfest/hacktober-issue-finder.py | 117 ++++ bot/exts/events/hacktoberfest/hacktoberstats.py | 437 +++++++++++++++ bot/exts/events/hacktoberfest/timeleft.py | 67 +++ bot/exts/halloween/hacktober-issue-finder.py | 117 ---- bot/exts/halloween/hacktoberstats.py | 437 --------------- bot/exts/halloween/timeleft.py | 67 --- bot/resources/advent_of_code/about.json | 27 - bot/resources/events/advent_of_code/about.json | 27 + 18 files changed, 1556 insertions(+), 1556 deletions(-) delete mode 100644 bot/exts/christmas/__init__.py delete mode 100644 bot/exts/christmas/advent_of_code/__init__.py delete mode 100644 bot/exts/christmas/advent_of_code/_caches.py delete mode 100644 bot/exts/christmas/advent_of_code/_cog.py delete mode 100644 bot/exts/christmas/advent_of_code/_helpers.py create mode 100644 bot/exts/events/advent_of_code/__init__.py create mode 100644 bot/exts/events/advent_of_code/_caches.py create mode 100644 bot/exts/events/advent_of_code/_cog.py create mode 100644 bot/exts/events/advent_of_code/_helpers.py create mode 100644 bot/exts/events/hacktoberfest/__init__.py create mode 100644 bot/exts/events/hacktoberfest/hacktober-issue-finder.py create mode 100644 bot/exts/events/hacktoberfest/hacktoberstats.py create mode 100644 bot/exts/events/hacktoberfest/timeleft.py delete mode 100644 bot/exts/halloween/hacktober-issue-finder.py delete mode 100644 bot/exts/halloween/hacktoberstats.py delete mode 100644 bot/exts/halloween/timeleft.py delete mode 100644 bot/resources/advent_of_code/about.json create mode 100644 bot/resources/events/advent_of_code/about.json (limited to 'bot') diff --git a/bot/exts/christmas/__init__.py b/bot/exts/christmas/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/bot/exts/christmas/advent_of_code/__init__.py b/bot/exts/christmas/advent_of_code/__init__.py deleted file mode 100644 index 3c521168..00000000 --- a/bot/exts/christmas/advent_of_code/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -from bot.bot import Bot - - -def setup(bot: Bot) -> None: - """Set up the Advent of Code extension.""" - # Import the Cog at runtime to prevent side effects like defining - # RedisCache instances too early. - from ._cog import AdventOfCode - - bot.add_cog(AdventOfCode(bot)) diff --git a/bot/exts/christmas/advent_of_code/_caches.py b/bot/exts/christmas/advent_of_code/_caches.py deleted file mode 100644 index 32d5394f..00000000 --- a/bot/exts/christmas/advent_of_code/_caches.py +++ /dev/null @@ -1,5 +0,0 @@ -import async_rediscache - -leaderboard_counts = async_rediscache.RedisCache(namespace="AOC_leaderboard_counts") -leaderboard_cache = async_rediscache.RedisCache(namespace="AOC_leaderboard_cache") -assigned_leaderboard = async_rediscache.RedisCache(namespace="AOC_assigned_leaderboard") diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py deleted file mode 100644 index bc2ccc04..00000000 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ /dev/null @@ -1,302 +0,0 @@ -import json -import logging -from datetime import datetime, timedelta -from pathlib import Path - -import arrow -import discord -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import ( - AdventOfCode as AocConfig, Channels, Colours, Emojis, Month, Roles, WHITELISTED_CHANNELS, -) -from bot.exts.christmas.advent_of_code import _helpers -from bot.utils.decorators import InChannelCheckFailure, in_month, whitelist_override, with_role -from bot.utils.extensions import invoke_help_command - -log = logging.getLogger(__name__) - -AOC_REQUEST_HEADER = {"user-agent": "PythonDiscord AoC Event Bot"} - -AOC_WHITELIST_RESTRICTED = WHITELISTED_CHANNELS + (Channels.advent_of_code_commands,) - -# Some commands can be run in the regular advent of code channel -# They aren't spammy and foster discussion -AOC_WHITELIST = AOC_WHITELIST_RESTRICTED + (Channels.advent_of_code,) - - -class AdventOfCode(commands.Cog): - """Advent of Code festivities! Ho Ho Ho!""" - - def __init__(self, bot: 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.about_aoc_filepath = Path("./bot/resources/advent_of_code/about.json") - self.cached_about_aoc = self._build_about_embed() - - notification_coro = _helpers.new_puzzle_notification(self.bot) - self.notification_task = self.bot.loop.create_task(notification_coro) - self.notification_task.set_name("Daily AoC Notification") - self.notification_task.add_done_callback(_helpers.background_task_callback) - - status_coro = _helpers.countdown_status(self.bot) - self.status_task = self.bot.loop.create_task(status_coro) - self.status_task.set_name("AoC Status Countdown") - self.status_task.add_done_callback(_helpers.background_task_callback) - - @commands.group(name="adventofcode", aliases=("aoc",)) - @whitelist_override(channels=AOC_WHITELIST) - async def adventofcode_group(self, ctx: commands.Context) -> None: - """All of the Advent of Code commands.""" - if not ctx.invoked_subcommand: - await invoke_help_command(ctx) - - @adventofcode_group.command( - name="subscribe", - aliases=("sub", "notifications", "notify", "notifs"), - brief="Notifications for new days" - ) - @whitelist_override(channels=AOC_WHITELIST) - async def aoc_subscribe(self, ctx: commands.Context) -> None: - """Assign the role for notifications about new days being ready.""" - current_year = datetime.now().year - if current_year != AocConfig.year: - await ctx.send(f"You can't subscribe to {current_year}'s Advent of Code announcements yet!") - return - - 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." - ) - - @in_month(Month.DECEMBER) - @adventofcode_group.command(name="unsubscribe", aliases=("unsub",), brief="Notifications for new days") - @whitelist_override(channels=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") - @whitelist_override(channels=AOC_WHITELIST) - async def aoc_countdown(self, ctx: commands.Context) -> None: - """Return time left until next day.""" - if not _helpers.is_in_advent(): - datetime_now = arrow.now(_helpers.EST) - - # Calculate the delta to this & next year's December 1st to see which one is closest and not in the past - this_year = arrow.get(datetime(datetime_now.year, 12, 1), _helpers.EST) - next_year = arrow.get(datetime(datetime_now.year + 1, 12, 1), _helpers.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( - "The Advent of Code event is not currently running. " - f"The next event will start in {delta_str}." - ) - return - - tomorrow, time_left = _helpers.time_left_to_est_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") - @whitelist_override(channels=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)") - @whitelist_override(channels=AOC_WHITELIST) - async def join_leaderboard(self, ctx: commands.Context) -> None: - """DM the user the information for joining the Python Discord leaderboard.""" - current_year = datetime.now().year - if current_year != AocConfig.year: - await ctx.send(f"The Python Discord leaderboard for {current_year} is not yet available!") - return - - author = ctx.author - log.info(f"{author.name} ({author.id}) has requested a PyDis AoC leaderboard code") - - if AocConfig.staff_leaderboard_id and any(r.id == Roles.helpers for r in author.roles): - join_code = AocConfig.leaderboards[AocConfig.staff_leaderboard_id].join_code - else: - try: - join_code = await _helpers.get_public_join_code(author) - except _helpers.FetchingLeaderboardFailed: - await ctx.send(":x: Failed to get join code! Notified maintainers.") - return - - if not join_code: - log.error(f"Failed to get a join code for user {author} ({author.id})") - error_embed = discord.Embed( - title="Unable to get join code", - description="Failed to get a join code to one of our boards. Please notify staff.", - colour=discord.Colour.red(), - ) - await ctx.send(embed=error_embed) - return - - info_str = [ - "To join our leaderboard, follow these steps:", - "• Log in on https://adventofcode.com", - "• Head over to https://adventofcode.com/leaderboard/private", - f"• Use this code `{join_code}` to join the Python Discord leaderboard!", - ] - try: - await author.send("\n".join(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) - - @in_month(Month.DECEMBER) - @adventofcode_group.command( - name="leaderboard", - aliases=("board", "lb"), - brief="Get a snapshot of the PyDis private AoC leaderboard", - ) - @whitelist_override(channels=AOC_WHITELIST_RESTRICTED) - async def aoc_leaderboard(self, ctx: commands.Context) -> None: - """Get the current top scorers of the Python Discord Leaderboard.""" - async with ctx.typing(): - try: - leaderboard = await _helpers.fetch_leaderboard() - except _helpers.FetchingLeaderboardFailed: - await ctx.send(":x: Unable to fetch leaderboard!") - return - - number_of_participants = leaderboard["number_of_participants"] - - top_count = min(AocConfig.leaderboard_displayed_members, number_of_participants) - header = f"Here's our current top {top_count}! {Emojis.christmas_tree * 3}" - - table = f"```\n{leaderboard['top_leaderboard']}\n```" - info_embed = _helpers.get_summary_embed(leaderboard) - - await ctx.send(content=f"{header}\n\n{table}", embed=info_embed) - - @in_month(Month.DECEMBER) - @adventofcode_group.command( - name="global", - aliases=("globalboard", "gb"), - brief="Get a link to the global leaderboard", - ) - @whitelist_override(channels=AOC_WHITELIST_RESTRICTED) - async def aoc_global_leaderboard(self, ctx: commands.Context) -> None: - """Get a link to the global Advent of Code leaderboard.""" - url = self.global_leaderboard_url - global_leaderboard = discord.Embed( - title="Advent of Code — Global Leaderboard", - description=f"You can find the global leaderboard [here]({url})." - ) - global_leaderboard.set_thumbnail(url=_helpers.AOC_EMBED_THUMBNAIL) - await ctx.send(embed=global_leaderboard) - - @adventofcode_group.command( - name="stats", - aliases=("dailystats", "ds"), - brief="Get daily statistics for the Python Discord leaderboard" - ) - @whitelist_override(channels=AOC_WHITELIST_RESTRICTED) - async def private_leaderboard_daily_stats(self, ctx: commands.Context) -> None: - """Send an embed with daily completion statistics for the Python Discord leaderboard.""" - try: - leaderboard = await _helpers.fetch_leaderboard() - except _helpers.FetchingLeaderboardFailed: - await ctx.send(":x: Can't fetch leaderboard for stats right now!") - return - - # The daily stats are serialized as JSON as they have to be cached in Redis - daily_stats = json.loads(leaderboard["daily_stats"]) - async with ctx.typing(): - lines = ["Day ⭐ ⭐⭐ | %⭐ %⭐⭐\n================================"] - for day, stars in daily_stats.items(): - star_one = stars["star_one"] - star_two = stars["star_two"] - p_star_one = star_one / leaderboard["number_of_participants"] - p_star_two = star_two / leaderboard["number_of_participants"] - lines.append( - f"{day:>2}) {star_one:>4} {star_two:>4} | {p_star_one:>7.2%} {p_star_two:>7.2%}" - ) - table = "\n".join(lines) - info_embed = _helpers.get_summary_embed(leaderboard) - await ctx.send(f"```\n{table}\n```", embed=info_embed) - - @with_role(Roles.admin) - @adventofcode_group.command( - name="refresh", - aliases=("fetch",), - brief="Force a refresh of the leaderboard cache.", - ) - async def refresh_leaderboard(self, ctx: commands.Context) -> None: - """ - Force a refresh of the leaderboard cache. - - Note: This should be used sparingly, as we want to prevent sending too - many requests to the Advent of Code server. - """ - async with ctx.typing(): - try: - await _helpers.fetch_leaderboard(invalidate_cache=True) - except _helpers.FetchingLeaderboardFailed: - await ctx.send(":x: Something went wrong while trying to refresh the cache!") - else: - await ctx.send("\N{OK Hand Sign} Refreshed leaderboard cache!") - - def cog_unload(self) -> None: - """Cancel season-related tasks on cog unload.""" - log.debug("Unloading the cog and canceling the background task.") - self.notification_task.cancel() - self.status_task.cancel() - - def _build_about_embed(self) -> discord.Embed: - """Build and return the informational "About AoC" embed from the resources file.""" - embed_fields = json.loads(self.about_aoc_filepath.read_text("utf8")) - - about_embed = discord.Embed( - title=self._base_url, - colour=Colours.soft_green, - url=self._base_url, - timestamp=datetime.utcnow() - ) - 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="Last Updated") - return about_embed - - async def cog_command_error(self, ctx: commands.Context, error: Exception) -> None: - """Custom error handler if an advent of code command was posted in the wrong channel.""" - if isinstance(error, InChannelCheckFailure): - await ctx.send(f":x: Please use <#{Channels.advent_of_code_commands}> for aoc commands instead.") - error.handled = True diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py deleted file mode 100644 index b64b44a6..00000000 --- a/bot/exts/christmas/advent_of_code/_helpers.py +++ /dev/null @@ -1,591 +0,0 @@ -import asyncio -import collections -import datetime -import json -import logging -import math -import operator -from typing import Any, Optional - -import aiohttp -import arrow -import discord - -from bot.bot import Bot -from bot.constants import AdventOfCode, Channels, Colours -from bot.exts.christmas.advent_of_code import _caches - -log = logging.getLogger(__name__) - -PASTE_URL = "https://paste.pythondiscord.com/documents" -RAW_PASTE_URL_TEMPLATE = "https://paste.pythondiscord.com/raw/{key}" - -# Base API URL for Advent of Code Private Leaderboards -AOC_API_URL = "https://adventofcode.com/{year}/leaderboard/private/view/{leaderboard_id}.json" -AOC_REQUEST_HEADER = {"user-agent": "PythonDiscord AoC Event Bot"} - -# Leaderboard Line Template -AOC_TABLE_TEMPLATE = "{rank: >4} | {name:25.25} | {score: >5} | {stars}" -HEADER = AOC_TABLE_TEMPLATE.format(rank="", name="Name", score="Score", stars="⭐, ⭐⭐") -HEADER = f"{HEADER}\n{'-' * (len(HEADER) + 2)}" -HEADER_LINES = len(HEADER.splitlines()) -TOP_LEADERBOARD_LINES = HEADER_LINES + AdventOfCode.leaderboard_displayed_members - -# Keys that need to be set for a cached leaderboard -REQUIRED_CACHE_KEYS = ( - "full_leaderboard", - "top_leaderboard", - "full_leaderboard_url", - "leaderboard_fetched_at", - "number_of_participants", - "daily_stats", -) - -AOC_EMBED_THUMBNAIL = ( - "https://raw.githubusercontent.com/python-discord" - "/branding/main/seasonal/christmas/server_icons/festive_256.gif" -) - -# Create an easy constant for the EST timezone -EST = "America/New_York" - -# Step size for the challenge countdown status -COUNTDOWN_STEP = 60 * 5 - -# Create namedtuple that combines a participant's name and their completion -# time for a specific star. We're going to use this later to order the results -# for each star to compute the rank score. -StarResult = collections.namedtuple("StarResult", "member_id completion_time") - - -class UnexpectedRedirect(aiohttp.ClientError): - """Raised when an unexpected redirect was detected.""" - - -class UnexpectedResponseStatus(aiohttp.ClientError): - """Raised when an unexpected redirect was detected.""" - - -class FetchingLeaderboardFailedError(Exception): - """Raised when one or more leaderboards could not be fetched at all.""" - - -def leaderboard_sorting_function(entry: tuple[str, dict]) -> tuple[int, int]: - """ - Provide a sorting value for our leaderboard. - - The leaderboard is sorted primarily on the score someone has received and - secondary on the number of stars someone has completed. - """ - result = entry[1] - return result["score"], result["star_2"] + result["star_1"] - - -def _parse_raw_leaderboard_data(raw_leaderboard_data: dict) -> dict: - """ - Parse the leaderboard data received from the AoC website. - - The data we receive from AoC is structured by member, not by day/star. This - means that we need to "transpose" the data to a per star structure in order - to calculate the rank scores each individual should get. - - As we need our data both "per participant" as well as "per day", we return - the parsed and analyzed data in both formats. - """ - # We need to get an aggregate of completion times for each star of each day, - # instead of per participant to compute the rank scores. This dictionary will - # provide such a transposed dataset. - star_results = collections.defaultdict(list) - - # As we're already iterating over the participants, we can record the number of - # first stars and second stars they've achieved right here and now. This means - # we won't have to iterate over the participants again later. - leaderboard = {} - - # The data we get from the AoC website is structured by member, not by day/star, - # which means we need to iterate over the members to transpose the data to a per - # star view. We need that per star view to compute rank scores per star. - for member in raw_leaderboard_data.values(): - name = member["name"] if member["name"] else f"Anonymous #{member['id']}" - member_id = member["id"] - leaderboard[member_id] = {"name": name, "score": 0, "star_1": 0, "star_2": 0} - - # Iterate over all days for this participant - for day, stars in member["completion_day_level"].items(): - # Iterate over the complete stars for this day for this participant - for star, data in stars.items(): - # Record completion of this star for this individual - leaderboard[member_id][f"star_{star}"] += 1 - - # Record completion datetime for this participant for this day/star - completion_time = datetime.datetime.fromtimestamp(int(data["get_star_ts"])) - star_results[(day, star)].append( - StarResult(member_id=member_id, completion_time=completion_time) - ) - - # Now that we have a transposed dataset that holds the completion time of all - # participants per star, we can compute the rank-based scores each participant - # should get for that star. - max_score = len(leaderboard) - for (day, _star), results in star_results.items(): - # If this day should not count in the ranking, skip it. - if day in AdventOfCode.ignored_days: - continue - - sorted_result = sorted(results, key=operator.attrgetter("completion_time")) - for rank, star_result in enumerate(sorted_result): - leaderboard[star_result.member_id]["score"] += max_score - rank - - # Since dictionaries now retain insertion order, let's use that - sorted_leaderboard = dict( - sorted(leaderboard.items(), key=leaderboard_sorting_function, reverse=True) - ) - - # Create summary stats for the stars completed for each day of the event. - daily_stats = {} - for day in range(1, 26): - day = str(day) - star_one = len(star_results.get((day, "1"), [])) - star_two = len(star_results.get((day, "2"), [])) - # By using a dictionary instead of namedtuple here, we can serialize - # this data to JSON in order to cache it in Redis. - daily_stats[day] = {"star_one": star_one, "star_two": star_two} - - return {"daily_stats": daily_stats, "leaderboard": sorted_leaderboard} - - -def _format_leaderboard(leaderboard: dict[str, dict]) -> str: - """Format the leaderboard using the AOC_TABLE_TEMPLATE.""" - leaderboard_lines = [HEADER] - for rank, data in enumerate(leaderboard.values(), start=1): - leaderboard_lines.append( - AOC_TABLE_TEMPLATE.format( - rank=rank, - name=data["name"], - score=str(data["score"]), - stars=f"({data['star_1']}, {data['star_2']})" - ) - ) - - return "\n".join(leaderboard_lines) - - -async def _leaderboard_request(url: str, board: str, cookies: dict) -> dict[str, Any]: - """Make a leaderboard request using the specified session cookie.""" - async with aiohttp.request("GET", url, headers=AOC_REQUEST_HEADER, cookies=cookies) as resp: - # The Advent of Code website redirects silently with a 200 response if a - # session cookie has expired, is invalid, or was not provided. - if str(resp.url) != url: - log.error(f"Fetching leaderboard `{board}` failed! Check the session cookie.") - raise UnexpectedRedirect(f"redirected unexpectedly to {resp.url} for board `{board}`") - - # Every status other than `200` is unexpected, not only 400+ - if not resp.status == 200: - log.error(f"Unexpected response `{resp.status}` while fetching leaderboard `{board}`") - raise UnexpectedResponseStatus(f"status `{resp.status}`") - - return await resp.json() - - -async def _fetch_leaderboard_data() -> dict[str, Any]: - """Fetch data for all leaderboards and return a pooled result.""" - year = AdventOfCode.year - - # We'll make our requests one at a time to not flood the AoC website with - # up to six simultaneous requests. This may take a little longer, but it - # does avoid putting unnecessary stress on the Advent of Code website. - - # Container to store the raw data of each leaderboard - participants = {} - for leaderboard in AdventOfCode.leaderboards.values(): - leaderboard_url = AOC_API_URL.format(year=year, leaderboard_id=leaderboard.id) - - # Two attempts, one with the original session cookie and one with the fallback session - for attempt in range(1, 3): - log.info(f"Attempting to fetch leaderboard `{leaderboard.id}` ({attempt}/2)") - cookies = {"session": leaderboard.session} - try: - raw_data = await _leaderboard_request(leaderboard_url, leaderboard.id, cookies) - except UnexpectedRedirect: - if cookies["session"] == AdventOfCode.fallback_session: - log.error("It seems like the fallback cookie has expired!") - raise FetchingLeaderboardFailedError from None - - # If we're here, it means that the original session did not - # work. Let's fall back to the fallback session. - leaderboard.use_fallback_session = True - continue - except aiohttp.ClientError: - # Don't retry, something unexpected is wrong and it may not be the session. - raise FetchingLeaderboardFailedError from None - else: - # Get the participants and store their current count. - board_participants = raw_data["members"] - await _caches.leaderboard_counts.set(leaderboard.id, len(board_participants)) - participants.update(board_participants) - break - else: - log.error(f"reached 'unreachable' state while fetching board `{leaderboard.id}`.") - raise FetchingLeaderboardFailedError - - log.info(f"Fetched leaderboard information for {len(participants)} participants") - return participants - - -async def _upload_leaderboard(leaderboard: str) -> str: - """Upload the full leaderboard to our paste service and return the URL.""" - async with aiohttp.request("POST", PASTE_URL, data=leaderboard) as resp: - try: - resp_json = await resp.json() - except Exception: - log.exception("Failed to upload full leaderboard to paste service") - return "" - - if "key" in resp_json: - return RAW_PASTE_URL_TEMPLATE.format(key=resp_json["key"]) - - log.error(f"Unexpected response from paste service while uploading leaderboard {resp_json}") - return "" - - -def _get_top_leaderboard(full_leaderboard: str) -> str: - """Get the leaderboard up to the maximum specified entries.""" - return "\n".join(full_leaderboard.splitlines()[:TOP_LEADERBOARD_LINES]) - - -@_caches.leaderboard_cache.atomic_transaction -async def fetch_leaderboard(invalidate_cache: bool = False) -> dict: - """ - Get the current Python Discord combined leaderboard. - - The leaderboard is cached and only fetched from the API if the current data - is older than the lifetime set in the constants. To prevent multiple calls - to this function fetching new leaderboard information in case of a cache - miss, this function is locked to one call at a time using a decorator. - """ - cached_leaderboard = await _caches.leaderboard_cache.to_dict() - - # Check if the cached leaderboard contains everything we expect it to. If it - # does not, this probably means the cache has not been created yet or has - # expired in Redis. This check also accounts for a malformed cache. - if invalidate_cache or any(key not in cached_leaderboard for key in REQUIRED_CACHE_KEYS): - log.info("No leaderboard cache available, fetching leaderboards...") - # Fetch the raw data - raw_leaderboard_data = await _fetch_leaderboard_data() - - # Parse it to extract "per star, per day" data and participant scores - parsed_leaderboard_data = _parse_raw_leaderboard_data(raw_leaderboard_data) - - leaderboard = parsed_leaderboard_data["leaderboard"] - number_of_participants = len(leaderboard) - formatted_leaderboard = _format_leaderboard(leaderboard) - full_leaderboard_url = await _upload_leaderboard(formatted_leaderboard) - leaderboard_fetched_at = datetime.datetime.utcnow().isoformat() - - cached_leaderboard = { - "full_leaderboard": formatted_leaderboard, - "top_leaderboard": _get_top_leaderboard(formatted_leaderboard), - "full_leaderboard_url": full_leaderboard_url, - "leaderboard_fetched_at": leaderboard_fetched_at, - "number_of_participants": number_of_participants, - "daily_stats": json.dumps(parsed_leaderboard_data["daily_stats"]), - } - - # Store the new values in Redis - await _caches.leaderboard_cache.update(cached_leaderboard) - - # Set an expiry on the leaderboard RedisCache - with await _caches.leaderboard_cache._get_pool_connection() as connection: - await connection.expire( - _caches.leaderboard_cache.namespace, - AdventOfCode.leaderboard_cache_expiry_seconds - ) - - return cached_leaderboard - - -def get_summary_embed(leaderboard: dict) -> discord.Embed: - """Get an embed with the current summary stats of the leaderboard.""" - leaderboard_url = leaderboard["full_leaderboard_url"] - refresh_minutes = AdventOfCode.leaderboard_cache_expiry_seconds // 60 - - aoc_embed = discord.Embed( - colour=Colours.soft_green, - timestamp=datetime.datetime.fromisoformat(leaderboard["leaderboard_fetched_at"]), - description=f"*The leaderboard is refreshed every {refresh_minutes} minutes.*" - ) - aoc_embed.add_field( - name="Number of Participants", - value=leaderboard["number_of_participants"], - inline=True, - ) - if leaderboard_url: - aoc_embed.add_field( - name="Full Leaderboard", - value=f"[Python Discord Leaderboard]({leaderboard_url})", - inline=True, - ) - aoc_embed.set_author(name="Advent of Code", url=leaderboard_url) - aoc_embed.set_footer(text="Last Updated") - aoc_embed.set_thumbnail(url=AOC_EMBED_THUMBNAIL) - - return aoc_embed - - -async def get_public_join_code(author: discord.Member) -> Optional[str]: - """ - Get the join code for one of the non-staff leaderboards. - - If a user has previously requested a join code and their assigned board - hasn't filled up yet, we'll return the same join code to prevent them from - getting join codes for multiple boards. - """ - # Make sure to fetch new leaderboard information if the cache is older than - # 30 minutes. While this still means that there could be a discrepancy - # between the current leaderboard state and the numbers we have here, this - # should work fairly well given the buffer of slots that we have. - await fetch_leaderboard() - previously_assigned_board = await _caches.assigned_leaderboard.get(author.id) - current_board_counts = await _caches.leaderboard_counts.to_dict() - - # Remove the staff board from the current board counts as it should be ignored. - current_board_counts.pop(AdventOfCode.staff_leaderboard_id, None) - - # If this user has already received a join code, we'll give them the - # exact same one to prevent them from joining multiple boards and taking - # up multiple slots. - if previously_assigned_board: - # Check if their previously assigned board still has room for them - if current_board_counts.get(previously_assigned_board, 0) < 200: - log.info(f"{author} ({author.id}) was already assigned to a board with open slots.") - return AdventOfCode.leaderboards[previously_assigned_board].join_code - - log.info( - f"User {author} ({author.id}) previously received the join code for " - f"board `{previously_assigned_board}`, but that board's now full. " - "Assigning another board to this user." - ) - - # If we don't have the current board counts cached, let's force fetching a new cache - if not current_board_counts: - log.warning("Leaderboard counts were missing from the cache unexpectedly!") - await fetch_leaderboard(invalidate_cache=True) - current_board_counts = await _caches.leaderboard_counts.to_dict() - - # Find the board with the current lowest participant count. As we can't - best_board, _count = min(current_board_counts.items(), key=operator.itemgetter(1)) - - if current_board_counts.get(best_board, 0) >= 200: - log.warning(f"User {author} `{author.id}` requested a join code, but all boards are full!") - return - - log.info(f"Assigning user {author} ({author.id}) to board `{best_board}`") - await _caches.assigned_leaderboard.set(author.id, best_board) - - # Return the join code for this board - return AdventOfCode.leaderboards[best_board].join_code - - -def is_in_advent() -> bool: - """ - Check if we're currently on an Advent of Code day, excluding 25 December. - - This helper function is used to check whether or not a feature that prepares - something for the next Advent of Code challenge should run. As the puzzle - published on the 25th is the last puzzle, this check excludes that date. - """ - return arrow.now(EST).day in range(1, 25) and arrow.now(EST).month == 12 - - -def time_left_to_est_midnight() -> tuple[datetime.datetime, datetime.timedelta]: - """Calculate the amount of time left until midnight EST/UTC-5.""" - # Change all time properties back to 00:00 - todays_midnight = arrow.now(EST).replace( - microsecond=0, - second=0, - minute=0, - hour=0 - ) - - # We want tomorrow so add a day on - tomorrow = todays_midnight + datetime.timedelta(days=1) - - # Calculate the timedelta between the current time and midnight - return tomorrow, tomorrow - arrow.now(EST) - - -async def wait_for_advent_of_code(*, hours_before: int = 1) -> None: - """ - Wait for the Advent of Code event to start. - - This function returns `hours_before` (default: 1) the Advent of Code - actually starts. This allows functions to schedule and execute code that - needs to run before the event starts. - - If the event has already started, this function returns immediately. - - Note: The "next Advent of Code" is determined based on the current value - of the `AOC_YEAR` environment variable. This allows callers to exit early - if we're already past the Advent of Code edition the bot is currently - configured for. - """ - start = arrow.get(datetime.datetime(AdventOfCode.year, 12, 1), EST) - target = start - datetime.timedelta(hours=hours_before) - now = arrow.now(EST) - - # If we've already reached or passed to target, we - # simply return immediately. - if now >= target: - return - - delta = target - now - await asyncio.sleep(delta.total_seconds()) - - -async def countdown_status(bot: Bot) -> None: - """ - Add the time until the next challenge is published to the bot's status. - - This function sleeps until 2 hours before the event and exists one hour - after the last challenge has been published. It will not start up again - automatically for next year's event, as it will wait for the environment - variable AOC_YEAR to be updated. - - This ensures that the task will only start sleeping again once the next - event approaches and we're making preparations for that event. - """ - log.debug("Initializing status countdown task.") - # We wait until 2 hours before the event starts. Then we - # set our first countdown status. - await wait_for_advent_of_code(hours_before=2) - - # Log that we're going to start with the countdown status. - log.info("The Advent of Code has started or will start soon, starting countdown status.") - - # Trying to change status too early in the bot's startup sequence will fail - # the task because the websocket instance has not yet been created. Waiting - # for this event means that both the websocket instance has been initialized - # and that the connection to Discord is mature enough to change the presence - # of the bot. - await bot.wait_until_guild_available() - - # Calculate when the task needs to stop running. To prevent the task from - # sleeping for the entire year, it will only wait in the currently - # configured year. This means that the task will only start hibernating once - # we start preparing the next event by changing environment variables. - last_challenge = arrow.get(datetime.datetime(AdventOfCode.year, 12, 25), EST) - end = last_challenge + datetime.timedelta(hours=1) - - while arrow.now(EST) < end: - _, time_left = time_left_to_est_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 = "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" - - log.trace(f"Changing presence to {playing!r}") - # 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 - log.trace(f"The countdown status task will sleep for {delay} seconds.") - await asyncio.sleep(delay) - - -async def new_puzzle_notification(bot: Bot) -> None: - """ - Announce the release of a new Advent of Code puzzle. - - This background task hibernates until just before the Advent of Code starts - and will then start announcing puzzles as they are published. After the - event has finished, this task will terminate. - """ - # We wake up one hour before the event starts to prepare the announcement - # of the release of the first puzzle. - await wait_for_advent_of_code(hours_before=1) - - log.info("The Advent of Code has started or will start soon, waking up notification task.") - - # Ensure that the guild cache is loaded so we can get the Advent of Code - # channel and role. - await bot.wait_until_guild_available() - aoc_channel = bot.get_channel(Channels.advent_of_code) - aoc_role = aoc_channel.guild.get_role(AdventOfCode.role_id) - - if not aoc_channel: - log.error("Could not find the AoC channel to send notification in") - return - - if not aoc_role: - log.error("Could not find the AoC role to announce the daily puzzle") - return - - # The last event day is 25 December, so we only have to schedule - # a reminder if the current day is before 25 December. - end = arrow.get(datetime.datetime(AdventOfCode.year, 12, 25), EST) - while arrow.now(EST) < end: - log.trace("Started puzzle notification loop.") - tomorrow, time_left = time_left_to_est_midnight() - - # Use `total_seconds` to get the time left in fractional seconds This - # should wake us up very close to the target. As a safe guard, the sleep - # duration is padded with 0.1 second to make sure we wake up after - # midnight. - sleep_seconds = time_left.total_seconds() + 0.1 - log.trace(f"The puzzle notification task will sleep for {sleep_seconds} seconds") - await asyncio.sleep(sleep_seconds) - - puzzle_url = f"https://adventofcode.com/{AdventOfCode.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 aoc_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!", - allowed_mentions=discord.AllowedMentions( - everyone=False, - users=False, - roles=[aoc_role], - ) - ) - - # Ensure that we don't send duplicate announcements by sleeping to well - # over midnight. This means we're certain to calculate the time to the - # next midnight at the top of the loop. - await asyncio.sleep(120) - - -def background_task_callback(task: asyncio.Task) -> None: - """Check if the finished background task failed to make sure we log errors.""" - if task.cancelled(): - log.info(f"Background task `{task.get_name()}` was cancelled.") - elif exception := task.exception(): - log.error(f"Background task `{task.get_name()}` failed:", exc_info=exception) - else: - log.info(f"Background task `{task.get_name()}` exited normally.") diff --git a/bot/exts/events/advent_of_code/__init__.py b/bot/exts/events/advent_of_code/__init__.py new file mode 100644 index 00000000..3c521168 --- /dev/null +++ b/bot/exts/events/advent_of_code/__init__.py @@ -0,0 +1,10 @@ +from bot.bot import Bot + + +def setup(bot: Bot) -> None: + """Set up the Advent of Code extension.""" + # Import the Cog at runtime to prevent side effects like defining + # RedisCache instances too early. + from ._cog import AdventOfCode + + bot.add_cog(AdventOfCode(bot)) diff --git a/bot/exts/events/advent_of_code/_caches.py b/bot/exts/events/advent_of_code/_caches.py new file mode 100644 index 00000000..32d5394f --- /dev/null +++ b/bot/exts/events/advent_of_code/_caches.py @@ -0,0 +1,5 @@ +import async_rediscache + +leaderboard_counts = async_rediscache.RedisCache(namespace="AOC_leaderboard_counts") +leaderboard_cache = async_rediscache.RedisCache(namespace="AOC_leaderboard_cache") +assigned_leaderboard = async_rediscache.RedisCache(namespace="AOC_assigned_leaderboard") diff --git a/bot/exts/events/advent_of_code/_cog.py b/bot/exts/events/advent_of_code/_cog.py new file mode 100644 index 00000000..ca60e517 --- /dev/null +++ b/bot/exts/events/advent_of_code/_cog.py @@ -0,0 +1,302 @@ +import json +import logging +from datetime import datetime, timedelta +from pathlib import Path + +import arrow +import discord +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import ( + AdventOfCode as AocConfig, Channels, Colours, Emojis, Month, Roles, WHITELISTED_CHANNELS, +) +from bot.exts.events.advent_of_code import _helpers +from bot.utils.decorators import InChannelCheckFailure, in_month, whitelist_override, with_role +from bot.utils.extensions import invoke_help_command + +log = logging.getLogger(__name__) + +AOC_REQUEST_HEADER = {"user-agent": "PythonDiscord AoC Event Bot"} + +AOC_WHITELIST_RESTRICTED = WHITELISTED_CHANNELS + (Channels.advent_of_code_commands,) + +# Some commands can be run in the regular advent of code channel +# They aren't spammy and foster discussion +AOC_WHITELIST = AOC_WHITELIST_RESTRICTED + (Channels.advent_of_code,) + + +class AdventOfCode(commands.Cog): + """Advent of Code festivities! Ho Ho Ho!""" + + def __init__(self, bot: 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.about_aoc_filepath = Path("./bot/resources/events/advent_of_code/about.json") + self.cached_about_aoc = self._build_about_embed() + + notification_coro = _helpers.new_puzzle_notification(self.bot) + self.notification_task = self.bot.loop.create_task(notification_coro) + self.notification_task.set_name("Daily AoC Notification") + self.notification_task.add_done_callback(_helpers.background_task_callback) + + status_coro = _helpers.countdown_status(self.bot) + self.status_task = self.bot.loop.create_task(status_coro) + self.status_task.set_name("AoC Status Countdown") + self.status_task.add_done_callback(_helpers.background_task_callback) + + @commands.group(name="adventofcode", aliases=("aoc",)) + @whitelist_override(channels=AOC_WHITELIST) + async def adventofcode_group(self, ctx: commands.Context) -> None: + """All of the Advent of Code commands.""" + if not ctx.invoked_subcommand: + await invoke_help_command(ctx) + + @adventofcode_group.command( + name="subscribe", + aliases=("sub", "notifications", "notify", "notifs"), + brief="Notifications for new days" + ) + @whitelist_override(channels=AOC_WHITELIST) + async def aoc_subscribe(self, ctx: commands.Context) -> None: + """Assign the role for notifications about new days being ready.""" + current_year = datetime.now().year + if current_year != AocConfig.year: + await ctx.send(f"You can't subscribe to {current_year}'s Advent of Code announcements yet!") + return + + 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." + ) + + @in_month(Month.DECEMBER) + @adventofcode_group.command(name="unsubscribe", aliases=("unsub",), brief="Notifications for new days") + @whitelist_override(channels=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") + @whitelist_override(channels=AOC_WHITELIST) + async def aoc_countdown(self, ctx: commands.Context) -> None: + """Return time left until next day.""" + if not _helpers.is_in_advent(): + datetime_now = arrow.now(_helpers.EST) + + # Calculate the delta to this & next year's December 1st to see which one is closest and not in the past + this_year = arrow.get(datetime(datetime_now.year, 12, 1), _helpers.EST) + next_year = arrow.get(datetime(datetime_now.year + 1, 12, 1), _helpers.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( + "The Advent of Code event is not currently running. " + f"The next event will start in {delta_str}." + ) + return + + tomorrow, time_left = _helpers.time_left_to_est_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") + @whitelist_override(channels=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)") + @whitelist_override(channels=AOC_WHITELIST) + async def join_leaderboard(self, ctx: commands.Context) -> None: + """DM the user the information for joining the Python Discord leaderboard.""" + current_year = datetime.now().year + if current_year != AocConfig.year: + await ctx.send(f"The Python Discord leaderboard for {current_year} is not yet available!") + return + + author = ctx.author + log.info(f"{author.name} ({author.id}) has requested a PyDis AoC leaderboard code") + + if AocConfig.staff_leaderboard_id and any(r.id == Roles.helpers for r in author.roles): + join_code = AocConfig.leaderboards[AocConfig.staff_leaderboard_id].join_code + else: + try: + join_code = await _helpers.get_public_join_code(author) + except _helpers.FetchingLeaderboardFailed: + await ctx.send(":x: Failed to get join code! Notified maintainers.") + return + + if not join_code: + log.error(f"Failed to get a join code for user {author} ({author.id})") + error_embed = discord.Embed( + title="Unable to get join code", + description="Failed to get a join code to one of our boards. Please notify staff.", + colour=discord.Colour.red(), + ) + await ctx.send(embed=error_embed) + return + + info_str = [ + "To join our leaderboard, follow these steps:", + "• Log in on https://adventofcode.com", + "• Head over to https://adventofcode.com/leaderboard/private", + f"• Use this code `{join_code}` to join the Python Discord leaderboard!", + ] + try: + await author.send("\n".join(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) + + @in_month(Month.DECEMBER) + @adventofcode_group.command( + name="leaderboard", + aliases=("board", "lb"), + brief="Get a snapshot of the PyDis private AoC leaderboard", + ) + @whitelist_override(channels=AOC_WHITELIST_RESTRICTED) + async def aoc_leaderboard(self, ctx: commands.Context) -> None: + """Get the current top scorers of the Python Discord Leaderboard.""" + async with ctx.typing(): + try: + leaderboard = await _helpers.fetch_leaderboard() + except _helpers.FetchingLeaderboardFailed: + await ctx.send(":x: Unable to fetch leaderboard!") + return + + number_of_participants = leaderboard["number_of_participants"] + + top_count = min(AocConfig.leaderboard_displayed_members, number_of_participants) + header = f"Here's our current top {top_count}! {Emojis.christmas_tree * 3}" + + table = f"```\n{leaderboard['top_leaderboard']}\n```" + info_embed = _helpers.get_summary_embed(leaderboard) + + await ctx.send(content=f"{header}\n\n{table}", embed=info_embed) + + @in_month(Month.DECEMBER) + @adventofcode_group.command( + name="global", + aliases=("globalboard", "gb"), + brief="Get a link to the global leaderboard", + ) + @whitelist_override(channels=AOC_WHITELIST_RESTRICTED) + async def aoc_global_leaderboard(self, ctx: commands.Context) -> None: + """Get a link to the global Advent of Code leaderboard.""" + url = self.global_leaderboard_url + global_leaderboard = discord.Embed( + title="Advent of Code — Global Leaderboard", + description=f"You can find the global leaderboard [here]({url})." + ) + global_leaderboard.set_thumbnail(url=_helpers.AOC_EMBED_THUMBNAIL) + await ctx.send(embed=global_leaderboard) + + @adventofcode_group.command( + name="stats", + aliases=("dailystats", "ds"), + brief="Get daily statistics for the Python Discord leaderboard" + ) + @whitelist_override(channels=AOC_WHITELIST_RESTRICTED) + async def private_leaderboard_daily_stats(self, ctx: commands.Context) -> None: + """Send an embed with daily completion statistics for the Python Discord leaderboard.""" + try: + leaderboard = await _helpers.fetch_leaderboard() + except _helpers.FetchingLeaderboardFailed: + await ctx.send(":x: Can't fetch leaderboard for stats right now!") + return + + # The daily stats are serialized as JSON as they have to be cached in Redis + daily_stats = json.loads(leaderboard["daily_stats"]) + async with ctx.typing(): + lines = ["Day ⭐ ⭐⭐ | %⭐ %⭐⭐\n================================"] + for day, stars in daily_stats.items(): + star_one = stars["star_one"] + star_two = stars["star_two"] + p_star_one = star_one / leaderboard["number_of_participants"] + p_star_two = star_two / leaderboard["number_of_participants"] + lines.append( + f"{day:>2}) {star_one:>4} {star_two:>4} | {p_star_one:>7.2%} {p_star_two:>7.2%}" + ) + table = "\n".join(lines) + info_embed = _helpers.get_summary_embed(leaderboard) + await ctx.send(f"```\n{table}\n```", embed=info_embed) + + @with_role(Roles.admin) + @adventofcode_group.command( + name="refresh", + aliases=("fetch",), + brief="Force a refresh of the leaderboard cache.", + ) + async def refresh_leaderboard(self, ctx: commands.Context) -> None: + """ + Force a refresh of the leaderboard cache. + + Note: This should be used sparingly, as we want to prevent sending too + many requests to the Advent of Code server. + """ + async with ctx.typing(): + try: + await _helpers.fetch_leaderboard(invalidate_cache=True) + except _helpers.FetchingLeaderboardFailed: + await ctx.send(":x: Something went wrong while trying to refresh the cache!") + else: + await ctx.send("\N{OK Hand Sign} Refreshed leaderboard cache!") + + def cog_unload(self) -> None: + """Cancel season-related tasks on cog unload.""" + log.debug("Unloading the cog and canceling the background task.") + self.notification_task.cancel() + self.status_task.cancel() + + def _build_about_embed(self) -> discord.Embed: + """Build and return the informational "About AoC" embed from the resources file.""" + embed_fields = json.loads(self.about_aoc_filepath.read_text("utf8")) + + about_embed = discord.Embed( + title=self._base_url, + colour=Colours.soft_green, + url=self._base_url, + timestamp=datetime.utcnow() + ) + 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="Last Updated") + return about_embed + + async def cog_command_error(self, ctx: commands.Context, error: Exception) -> None: + """Custom error handler if an advent of code command was posted in the wrong channel.""" + if isinstance(error, InChannelCheckFailure): + await ctx.send(f":x: Please use <#{Channels.advent_of_code_commands}> for aoc commands instead.") + error.handled = True diff --git a/bot/exts/events/advent_of_code/_helpers.py b/bot/exts/events/advent_of_code/_helpers.py new file mode 100644 index 00000000..43aa5a7e --- /dev/null +++ b/bot/exts/events/advent_of_code/_helpers.py @@ -0,0 +1,591 @@ +import asyncio +import collections +import datetime +import json +import logging +import math +import operator +from typing import Any, Optional + +import aiohttp +import arrow +import discord + +from bot.bot import Bot +from bot.constants import AdventOfCode, Channels, Colours +from bot.exts.advent_of_code import _caches + +log = logging.getLogger(__name__) + +PASTE_URL = "https://paste.pythondiscord.com/documents" +RAW_PASTE_URL_TEMPLATE = "https://paste.pythondiscord.com/raw/{key}" + +# Base API URL for Advent of Code Private Leaderboards +AOC_API_URL = "https://adventofcode.com/{year}/leaderboard/private/view/{leaderboard_id}.json" +AOC_REQUEST_HEADER = {"user-agent": "PythonDiscord AoC Event Bot"} + +# Leaderboard Line Template +AOC_TABLE_TEMPLATE = "{rank: >4} | {name:25.25} | {score: >5} | {stars}" +HEADER = AOC_TABLE_TEMPLATE.format(rank="", name="Name", score="Score", stars="⭐, ⭐⭐") +HEADER = f"{HEADER}\n{'-' * (len(HEADER) + 2)}" +HEADER_LINES = len(HEADER.splitlines()) +TOP_LEADERBOARD_LINES = HEADER_LINES + AdventOfCode.leaderboard_displayed_members + +# Keys that need to be set for a cached leaderboard +REQUIRED_CACHE_KEYS = ( + "full_leaderboard", + "top_leaderboard", + "full_leaderboard_url", + "leaderboard_fetched_at", + "number_of_participants", + "daily_stats", +) + +AOC_EMBED_THUMBNAIL = ( + "https://raw.githubusercontent.com/python-discord" + "/branding/main/seasonal/christmas/server_icons/festive_256.gif" +) + +# Create an easy constant for the EST timezone +EST = "America/New_York" + +# Step size for the challenge countdown status +COUNTDOWN_STEP = 60 * 5 + +# Create namedtuple that combines a participant's name and their completion +# time for a specific star. We're going to use this later to order the results +# for each star to compute the rank score. +StarResult = collections.namedtuple("StarResult", "member_id completion_time") + + +class UnexpectedRedirect(aiohttp.ClientError): + """Raised when an unexpected redirect was detected.""" + + +class UnexpectedResponseStatus(aiohttp.ClientError): + """Raised when an unexpected redirect was detected.""" + + +class FetchingLeaderboardFailedError(Exception): + """Raised when one or more leaderboards could not be fetched at all.""" + + +def leaderboard_sorting_function(entry: tuple[str, dict]) -> tuple[int, int]: + """ + Provide a sorting value for our leaderboard. + + The leaderboard is sorted primarily on the score someone has received and + secondary on the number of stars someone has completed. + """ + result = entry[1] + return result["score"], result["star_2"] + result["star_1"] + + +def _parse_raw_leaderboard_data(raw_leaderboard_data: dict) -> dict: + """ + Parse the leaderboard data received from the AoC website. + + The data we receive from AoC is structured by member, not by day/star. This + means that we need to "transpose" the data to a per star structure in order + to calculate the rank scores each individual should get. + + As we need our data both "per participant" as well as "per day", we return + the parsed and analyzed data in both formats. + """ + # We need to get an aggregate of completion times for each star of each day, + # instead of per participant to compute the rank scores. This dictionary will + # provide such a transposed dataset. + star_results = collections.defaultdict(list) + + # As we're already iterating over the participants, we can record the number of + # first stars and second stars they've achieved right here and now. This means + # we won't have to iterate over the participants again later. + leaderboard = {} + + # The data we get from the AoC website is structured by member, not by day/star, + # which means we need to iterate over the members to transpose the data to a per + # star view. We need that per star view to compute rank scores per star. + for member in raw_leaderboard_data.values(): + name = member["name"] if member["name"] else f"Anonymous #{member['id']}" + member_id = member["id"] + leaderboard[member_id] = {"name": name, "score": 0, "star_1": 0, "star_2": 0} + + # Iterate over all days for this participant + for day, stars in member["completion_day_level"].items(): + # Iterate over the complete stars for this day for this participant + for star, data in stars.items(): + # Record completion of this star for this individual + leaderboard[member_id][f"star_{star}"] += 1 + + # Record completion datetime for this participant for this day/star + completion_time = datetime.datetime.fromtimestamp(int(data["get_star_ts"])) + star_results[(day, star)].append( + StarResult(member_id=member_id, completion_time=completion_time) + ) + + # Now that we have a transposed dataset that holds the completion time of all + # participants per star, we can compute the rank-based scores each participant + # should get for that star. + max_score = len(leaderboard) + for (day, _star), results in star_results.items(): + # If this day should not count in the ranking, skip it. + if day in AdventOfCode.ignored_days: + continue + + sorted_result = sorted(results, key=operator.attrgetter("completion_time")) + for rank, star_result in enumerate(sorted_result): + leaderboard[star_result.member_id]["score"] += max_score - rank + + # Since dictionaries now retain insertion order, let's use that + sorted_leaderboard = dict( + sorted(leaderboard.items(), key=leaderboard_sorting_function, reverse=True) + ) + + # Create summary stats for the stars completed for each day of the event. + daily_stats = {} + for day in range(1, 26): + day = str(day) + star_one = len(star_results.get((day, "1"), [])) + star_two = len(star_results.get((day, "2"), [])) + # By using a dictionary instead of namedtuple here, we can serialize + # this data to JSON in order to cache it in Redis. + daily_stats[day] = {"star_one": star_one, "star_two": star_two} + + return {"daily_stats": daily_stats, "leaderboard": sorted_leaderboard} + + +def _format_leaderboard(leaderboard: dict[str, dict]) -> str: + """Format the leaderboard using the AOC_TABLE_TEMPLATE.""" + leaderboard_lines = [HEADER] + for rank, data in enumerate(leaderboard.values(), start=1): + leaderboard_lines.append( + AOC_TABLE_TEMPLATE.format( + rank=rank, + name=data["name"], + score=str(data["score"]), + stars=f"({data['star_1']}, {data['star_2']})" + ) + ) + + return "\n".join(leaderboard_lines) + + +async def _leaderboard_request(url: str, board: str, cookies: dict) -> dict[str, Any]: + """Make a leaderboard request using the specified session cookie.""" + async with aiohttp.request("GET", url, headers=AOC_REQUEST_HEADER, cookies=cookies) as resp: + # The Advent of Code website redirects silently with a 200 response if a + # session cookie has expired, is invalid, or was not provided. + if str(resp.url) != url: + log.error(f"Fetching leaderboard `{board}` failed! Check the session cookie.") + raise UnexpectedRedirect(f"redirected unexpectedly to {resp.url} for board `{board}`") + + # Every status other than `200` is unexpected, not only 400+ + if not resp.status == 200: + log.error(f"Unexpected response `{resp.status}` while fetching leaderboard `{board}`") + raise UnexpectedResponseStatus(f"status `{resp.status}`") + + return await resp.json() + + +async def _fetch_leaderboard_data() -> dict[str, Any]: + """Fetch data for all leaderboards and return a pooled result.""" + year = AdventOfCode.year + + # We'll make our requests one at a time to not flood the AoC website with + # up to six simultaneous requests. This may take a little longer, but it + # does avoid putting unnecessary stress on the Advent of Code website. + + # Container to store the raw data of each leaderboard + participants = {} + for leaderboard in AdventOfCode.leaderboards.values(): + leaderboard_url = AOC_API_URL.format(year=year, leaderboard_id=leaderboard.id) + + # Two attempts, one with the original session cookie and one with the fallback session + for attempt in range(1, 3): + log.info(f"Attempting to fetch leaderboard `{leaderboard.id}` ({attempt}/2)") + cookies = {"session": leaderboard.session} + try: + raw_data = await _leaderboard_request(leaderboard_url, leaderboard.id, cookies) + except UnexpectedRedirect: + if cookies["session"] == AdventOfCode.fallback_session: + log.error("It seems like the fallback cookie has expired!") + raise FetchingLeaderboardFailedError from None + + # If we're here, it means that the original session did not + # work. Let's fall back to the fallback session. + leaderboard.use_fallback_session = True + continue + except aiohttp.ClientError: + # Don't retry, something unexpected is wrong and it may not be the session. + raise FetchingLeaderboardFailedError from None + else: + # Get the participants and store their current count. + board_participants = raw_data["members"] + await _caches.leaderboard_counts.set(leaderboard.id, len(board_participants)) + participants.update(board_participants) + break + else: + log.error(f"reached 'unreachable' state while fetching board `{leaderboard.id}`.") + raise FetchingLeaderboardFailedError + + log.info(f"Fetched leaderboard information for {len(participants)} participants") + return participants + + +async def _upload_leaderboard(leaderboard: str) -> str: + """Upload the full leaderboard to our paste service and return the URL.""" + async with aiohttp.request("POST", PASTE_URL, data=leaderboard) as resp: + try: + resp_json = await resp.json() + except Exception: + log.exception("Failed to upload full leaderboard to paste service") + return "" + + if "key" in resp_json: + return RAW_PASTE_URL_TEMPLATE.format(key=resp_json["key"]) + + log.error(f"Unexpected response from paste service while uploading leaderboard {resp_json}") + return "" + + +def _get_top_leaderboard(full_leaderboard: str) -> str: + """Get the leaderboard up to the maximum specified entries.""" + return "\n".join(full_leaderboard.splitlines()[:TOP_LEADERBOARD_LINES]) + + +@_caches.leaderboard_cache.atomic_transaction +async def fetch_leaderboard(invalidate_cache: bool = False) -> dict: + """ + Get the current Python Discord combined leaderboard. + + The leaderboard is cached and only fetched from the API if the current data + is older than the lifetime set in the constants. To prevent multiple calls + to this function fetching new leaderboard information in case of a cache + miss, this function is locked to one call at a time using a decorator. + """ + cached_leaderboard = await _caches.leaderboard_cache.to_dict() + + # Check if the cached leaderboard contains everything we expect it to. If it + # does not, this probably means the cache has not been created yet or has + # expired in Redis. This check also accounts for a malformed cache. + if invalidate_cache or any(key not in cached_leaderboard for key in REQUIRED_CACHE_KEYS): + log.info("No leaderboard cache available, fetching leaderboards...") + # Fetch the raw data + raw_leaderboard_data = await _fetch_leaderboard_data() + + # Parse it to extract "per star, per day" data and participant scores + parsed_leaderboard_data = _parse_raw_leaderboard_data(raw_leaderboard_data) + + leaderboard = parsed_leaderboard_data["leaderboard"] + number_of_participants = len(leaderboard) + formatted_leaderboard = _format_leaderboard(leaderboard) + full_leaderboard_url = await _upload_leaderboard(formatted_leaderboard) + leaderboard_fetched_at = datetime.datetime.utcnow().isoformat() + + cached_leaderboard = { + "full_leaderboard": formatted_leaderboard, + "top_leaderboard": _get_top_leaderboard(formatted_leaderboard), + "full_leaderboard_url": full_leaderboard_url, + "leaderboard_fetched_at": leaderboard_fetched_at, + "number_of_participants": number_of_participants, + "daily_stats": json.dumps(parsed_leaderboard_data["daily_stats"]), + } + + # Store the new values in Redis + await _caches.leaderboard_cache.update(cached_leaderboard) + + # Set an expiry on the leaderboard RedisCache + with await _caches.leaderboard_cache._get_pool_connection() as connection: + await connection.expire( + _caches.leaderboard_cache.namespace, + AdventOfCode.leaderboard_cache_expiry_seconds + ) + + return cached_leaderboard + + +def get_summary_embed(leaderboard: dict) -> discord.Embed: + """Get an embed with the current summary stats of the leaderboard.""" + leaderboard_url = leaderboard["full_leaderboard_url"] + refresh_minutes = AdventOfCode.leaderboard_cache_expiry_seconds // 60 + + aoc_embed = discord.Embed( + colour=Colours.soft_green, + timestamp=datetime.datetime.fromisoformat(leaderboard["leaderboard_fetched_at"]), + description=f"*The leaderboard is refreshed every {refresh_minutes} minutes.*" + ) + aoc_embed.add_field( + name="Number of Participants", + value=leaderboard["number_of_participants"], + inline=True, + ) + if leaderboard_url: + aoc_embed.add_field( + name="Full Leaderboard", + value=f"[Python Discord Leaderboard]({leaderboard_url})", + inline=True, + ) + aoc_embed.set_author(name="Advent of Code", url=leaderboard_url) + aoc_embed.set_footer(text="Last Updated") + aoc_embed.set_thumbnail(url=AOC_EMBED_THUMBNAIL) + + return aoc_embed + + +async def get_public_join_code(author: discord.Member) -> Optional[str]: + """ + Get the join code for one of the non-staff leaderboards. + + If a user has previously requested a join code and their assigned board + hasn't filled up yet, we'll return the same join code to prevent them from + getting join codes for multiple boards. + """ + # Make sure to fetch new leaderboard information if the cache is older than + # 30 minutes. While this still means that there could be a discrepancy + # between the current leaderboard state and the numbers we have here, this + # should work fairly well given the buffer of slots that we have. + await fetch_leaderboard() + previously_assigned_board = await _caches.assigned_leaderboard.get(author.id) + current_board_counts = await _caches.leaderboard_counts.to_dict() + + # Remove the staff board from the current board counts as it should be ignored. + current_board_counts.pop(AdventOfCode.staff_leaderboard_id, None) + + # If this user has already received a join code, we'll give them the + # exact same one to prevent them from joining multiple boards and taking + # up multiple slots. + if previously_assigned_board: + # Check if their previously assigned board still has room for them + if current_board_counts.get(previously_assigned_board, 0) < 200: + log.info(f"{author} ({author.id}) was already assigned to a board with open slots.") + return AdventOfCode.leaderboards[previously_assigned_board].join_code + + log.info( + f"User {author} ({author.id}) previously received the join code for " + f"board `{previously_assigned_board}`, but that board's now full. " + "Assigning another board to this user." + ) + + # If we don't have the current board counts cached, let's force fetching a new cache + if not current_board_counts: + log.warning("Leaderboard counts were missing from the cache unexpectedly!") + await fetch_leaderboard(invalidate_cache=True) + current_board_counts = await _caches.leaderboard_counts.to_dict() + + # Find the board with the current lowest participant count. As we can't + best_board, _count = min(current_board_counts.items(), key=operator.itemgetter(1)) + + if current_board_counts.get(best_board, 0) >= 200: + log.warning(f"User {author} `{author.id}` requested a join code, but all boards are full!") + return + + log.info(f"Assigning user {author} ({author.id}) to board `{best_board}`") + await _caches.assigned_leaderboard.set(author.id, best_board) + + # Return the join code for this board + return AdventOfCode.leaderboards[best_board].join_code + + +def is_in_advent() -> bool: + """ + Check if we're currently on an Advent of Code day, excluding 25 December. + + This helper function is used to check whether or not a feature that prepares + something for the next Advent of Code challenge should run. As the puzzle + published on the 25th is the last puzzle, this check excludes that date. + """ + return arrow.now(EST).day in range(1, 25) and arrow.now(EST).month == 12 + + +def time_left_to_est_midnight() -> tuple[datetime.datetime, datetime.timedelta]: + """Calculate the amount of time left until midnight EST/UTC-5.""" + # Change all time properties back to 00:00 + todays_midnight = arrow.now(EST).replace( + microsecond=0, + second=0, + minute=0, + hour=0 + ) + + # We want tomorrow so add a day on + tomorrow = todays_midnight + datetime.timedelta(days=1) + + # Calculate the timedelta between the current time and midnight + return tomorrow, tomorrow - arrow.now(EST) + + +async def wait_for_advent_of_code(*, hours_before: int = 1) -> None: + """ + Wait for the Advent of Code event to start. + + This function returns `hours_before` (default: 1) the Advent of Code + actually starts. This allows functions to schedule and execute code that + needs to run before the event starts. + + If the event has already started, this function returns immediately. + + Note: The "next Advent of Code" is determined based on the current value + of the `AOC_YEAR` environment variable. This allows callers to exit early + if we're already past the Advent of Code edition the bot is currently + configured for. + """ + start = arrow.get(datetime.datetime(AdventOfCode.year, 12, 1), EST) + target = start - datetime.timedelta(hours=hours_before) + now = arrow.now(EST) + + # If we've already reached or passed to target, we + # simply return immediately. + if now >= target: + return + + delta = target - now + await asyncio.sleep(delta.total_seconds()) + + +async def countdown_status(bot: Bot) -> None: + """ + Add the time until the next challenge is published to the bot's status. + + This function sleeps until 2 hours before the event and exists one hour + after the last challenge has been published. It will not start up again + automatically for next year's event, as it will wait for the environment + variable AOC_YEAR to be updated. + + This ensures that the task will only start sleeping again once the next + event approaches and we're making preparations for that event. + """ + log.debug("Initializing status countdown task.") + # We wait until 2 hours before the event starts. Then we + # set our first countdown status. + await wait_for_advent_of_code(hours_before=2) + + # Log that we're going to start with the countdown status. + log.info("The Advent of Code has started or will start soon, starting countdown status.") + + # Trying to change status too early in the bot's startup sequence will fail + # the task because the websocket instance has not yet been created. Waiting + # for this event means that both the websocket instance has been initialized + # and that the connection to Discord is mature enough to change the presence + # of the bot. + await bot.wait_until_guild_available() + + # Calculate when the task needs to stop running. To prevent the task from + # sleeping for the entire year, it will only wait in the currently + # configured year. This means that the task will only start hibernating once + # we start preparing the next event by changing environment variables. + last_challenge = arrow.get(datetime.datetime(AdventOfCode.year, 12, 25), EST) + end = last_challenge + datetime.timedelta(hours=1) + + while arrow.now(EST) < end: + _, time_left = time_left_to_est_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 = "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" + + log.trace(f"Changing presence to {playing!r}") + # 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 + log.trace(f"The countdown status task will sleep for {delay} seconds.") + await asyncio.sleep(delay) + + +async def new_puzzle_notification(bot: Bot) -> None: + """ + Announce the release of a new Advent of Code puzzle. + + This background task hibernates until just before the Advent of Code starts + and will then start announcing puzzles as they are published. After the + event has finished, this task will terminate. + """ + # We wake up one hour before the event starts to prepare the announcement + # of the release of the first puzzle. + await wait_for_advent_of_code(hours_before=1) + + log.info("The Advent of Code has started or will start soon, waking up notification task.") + + # Ensure that the guild cache is loaded so we can get the Advent of Code + # channel and role. + await bot.wait_until_guild_available() + aoc_channel = bot.get_channel(Channels.advent_of_code) + aoc_role = aoc_channel.guild.get_role(AdventOfCode.role_id) + + if not aoc_channel: + log.error("Could not find the AoC channel to send notification in") + return + + if not aoc_role: + log.error("Could not find the AoC role to announce the daily puzzle") + return + + # The last event day is 25 December, so we only have to schedule + # a reminder if the current day is before 25 December. + end = arrow.get(datetime.datetime(AdventOfCode.year, 12, 25), EST) + while arrow.now(EST) < end: + log.trace("Started puzzle notification loop.") + tomorrow, time_left = time_left_to_est_midnight() + + # Use `total_seconds` to get the time left in fractional seconds This + # should wake us up very close to the target. As a safe guard, the sleep + # duration is padded with 0.1 second to make sure we wake up after + # midnight. + sleep_seconds = time_left.total_seconds() + 0.1 + log.trace(f"The puzzle notification task will sleep for {sleep_seconds} seconds") + await asyncio.sleep(sleep_seconds) + + puzzle_url = f"https://adventofcode.com/{AdventOfCode.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 aoc_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!", + allowed_mentions=discord.AllowedMentions( + everyone=False, + users=False, + roles=[aoc_role], + ) + ) + + # Ensure that we don't send duplicate announcements by sleeping to well + # over midnight. This means we're certain to calculate the time to the + # next midnight at the top of the loop. + await asyncio.sleep(120) + + +def background_task_callback(task: asyncio.Task) -> None: + """Check if the finished background task failed to make sure we log errors.""" + if task.cancelled(): + log.info(f"Background task `{task.get_name()}` was cancelled.") + elif exception := task.exception(): + log.error(f"Background task `{task.get_name()}` failed:", exc_info=exception) + else: + log.info(f"Background task `{task.get_name()}` exited normally.") diff --git a/bot/exts/events/hacktoberfest/__init__.py b/bot/exts/events/hacktoberfest/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bot/exts/events/hacktoberfest/hacktober-issue-finder.py b/bot/exts/events/hacktoberfest/hacktober-issue-finder.py new file mode 100644 index 00000000..e3053851 --- /dev/null +++ b/bot/exts/events/hacktoberfest/hacktober-issue-finder.py @@ -0,0 +1,117 @@ +import datetime +import logging +import random +from typing import Optional + +import discord +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Month, Tokens +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" + +REQUEST_HEADERS = { + "User-Agent": "Python Discord Hacktoberbot", + "Accept": "application / vnd.github.v3 + json" +} +if GITHUB_TOKEN := Tokens.github: + REQUEST_HEADERS["Authorization"] = f"token {GITHUB_TOKEN}" + + +class HacktoberIssues(commands.Cog): + """Find a random hacktober python issue on GitHub.""" + + def __init__(self, bot: 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. + """ + async 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 + + 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 self.bot.http_session.get(url, headers=REQUEST_HEADERS) as response: + if response.status != 200: + log.error(f"expected 200 status (got {response.status}) by the GitHub api.") + await ctx.send( + f"ERROR: expected 200 status (got {response.status}) by the GitHub api.\n" + f"{await response.text()}" + ) + return None + data = await response.json() + + if len(data["items"]) == 0: + log.error(f"no issues returned by GitHub API, with url: {response.url}") + await ctx.send(f"ERROR: no issues returned by 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[:500] + "..." if len(body) > 500 else 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: Bot) -> None: + """Load the HacktoberIssue finder.""" + bot.add_cog(HacktoberIssues(bot)) diff --git a/bot/exts/events/hacktoberfest/hacktoberstats.py b/bot/exts/events/hacktoberfest/hacktoberstats.py new file mode 100644 index 00000000..72067dbe --- /dev/null +++ b/bot/exts/events/hacktoberfest/hacktoberstats.py @@ -0,0 +1,437 @@ +import logging +import random +import re +from collections import Counter +from datetime import datetime, timedelta +from typing import Optional, Union +from urllib.parse import quote_plus + +import discord +from async_rediscache import RedisCache +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Colours, Month, NEGATIVE_REPLIES, Tokens +from bot.utils.decorators import in_month + +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 +REVIEW_DAYS = 14 # number of days needed after PR can be mature + +REQUEST_HEADERS = {"User-Agent": "Python Discord Hacktoberbot"} +# using repo topics API during preview period requires an accept header +GITHUB_TOPICS_ACCEPT_HEADER = {"Accept": "application/vnd.github.mercy-preview+json"} +if GITHUB_TOKEN := Tokens.github: + REQUEST_HEADERS["Authorization"] = f"token {GITHUB_TOKEN}" + GITHUB_TOPICS_ACCEPT_HEADER["Authorization"] = f"token {GITHUB_TOKEN}" + +GITHUB_NONEXISTENT_USER_MESSAGE = ( + "The listed users cannot be searched either because the users do not exist " + "or you do not have permission to view the users." +) + + +class HacktoberStats(commands.Cog): + """Hacktoberfest statistics Cog.""" + + # Stores mapping of user IDs and GitHub usernames + linked_accounts = RedisCache() + + def __init__(self, bot: Bot): + self.bot = bot + + @in_month(Month.SEPTEMBER, Month.OCTOBER, Month.NOVEMBER) + @commands.group(name="hacktoberstats", aliases=("hackstats",), invoke_without_command=True) + 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 = self._author_mention_from_context(ctx) + + if await self.linked_accounts.contains(author_id): + github_username = await self.linked_accounts.get(author_id) + 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```\n{ctx.prefix}hackstats link github_username\n```\n" + f"Or query GitHub stats directly using:\n```\n{ctx.prefix}hackstats github_username\n```" + ) + await ctx.send(msg) + return + + await self.get_stats(ctx, github_username) + + @in_month(Month.SEPTEMBER, Month.OCTOBER, Month.NOVEMBER) + @hacktoberstats_group.command(name="link") + 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 in Redis: User ID => GitHub Username. + """ + author_id, author_mention = self._author_mention_from_context(ctx) + if github_username: + if await self.linked_accounts.contains(author_id): + old_username = await self.linked_accounts.get(author_id) + log.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: + log.info(f"{author_id} has added a github link to '{github_username}'") + await ctx.send(f"{author_mention}, your GitHub username has been added") + + await self.linked_accounts.set(author_id, github_username) + else: + log.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.SEPTEMBER, Month.OCTOBER, Month.NOVEMBER) + @hacktoberstats_group.command(name="unlink") + async def unlink_user(self, ctx: commands.Context) -> None: + """Remove the invoking user's account link from the log.""" + author_id, author_mention = self._author_mention_from_context(ctx) + + stored_user = await 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") + + 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 an 'invalid' or 'spam' label are ignored + + For PRs created after October 3rd, they have to be in a repository that has a + 'hacktoberfest' topic, unless the PR is labelled 'hacktoberfest-accepted' for it + to count. + + 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 is None: # Will be None if the user was not found + await ctx.send( + embed=discord.Embed( + title=random.choice(NEGATIVE_REPLIES), + description=f"GitHub user `{github_username}` was not found.", + colour=discord.Colour.red() + ) + ) + return + + if prs: + stats_embed = await self.build_embed(github_username, prs) + await ctx.send("Here are some stats!", embed=stats_embed) + else: + await ctx.send(f"No valid Hacktoberfest PRs found for '{github_username}'") + + async 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}'") + in_review, accepted = await self._categorize_prs(prs) + + n = len(accepted) + len(in_review) # Total number of PRs + if n >= PRS_FOR_SHIRT: + shirtstr = f"**{github_username} is eligible for a T-shirt or a tree!**" + elif n == PRS_FOR_SHIRT - 1: + shirtstr = f"**{github_username} is 1 PR away from a T-shirt or a tree!**" + else: + shirtstr = f"**{github_username} is {PRS_FOR_SHIRT - n} PRs away from a T-shirt or a tree!**" + + stats_embed = discord.Embed( + title=f"{github_username}'s Hacktoberfest", + color=Colours.purple, + description=( + f"{github_username} has made {n} valid " + f"{self._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://avatars1.githubusercontent.com/u/35706162?s=200&v=4" + ) + + # This will handle when no PRs in_review or accepted + review_str = self._build_prs_string(in_review, github_username) or "None" + accepted_str = self._build_prs_string(accepted, github_username) or "None" + stats_embed.add_field( + name=":clock1: In Review", + value=review_str + ) + stats_embed.add_field( + name=":tada: Accepted", + value=accepted_str + ) + + logging.info(f"Hacktoberfest PR built for GitHub user '{github_username}'") + return stats_embed + + async def get_october_prs(self, github_username: str) -> Optional[list[dict]]: + """ + Query GitHub's API for PRs created during the month of October by github_username. + + PRs with an 'invalid' or 'spam' label are ignored unless it is merged or approved + + For PRs created after October 3rd, they have to be in a repository that has a + 'hacktoberfest' topic, unless the PR is labelled 'hacktoberfest-accepted' for it + to count. + + 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/sir-lancebot") + "created_at": datetime.datetime + "number": int + } + + Otherwise, return empty list. + None will be returned when the GitHub user was not found. + """ + log.info(f"Fetching Hacktoberfest Stats for GitHub user: '{github_username}'") + base_url = "https://api.github.com/search/issues" + action_type = "pr" + is_query = "public" + not_query = "draft" + date_range = f"{CURRENT_YEAR}-09-30T10:00Z..{CURRENT_YEAR}-11-01T12:00Z" + per_page = "300" + query_params = ( + f"+type:{action_type}" + f"+is:{is_query}" + f"+author:{quote_plus(github_username)}" + f"+-is:{not_query}" + f"+created:{date_range}" + f"&per_page={per_page}" + ) + + log.debug(f"GitHub query parameters generated: {query_params}") + + jsonresp = await self._fetch_url(base_url, REQUEST_HEADERS, {"q": query_params}) + if "message" in jsonresp: + # One of the parameters is invalid, short circuit for now + api_message = jsonresp["errors"][0]["message"] + + # Ignore logging non-existent users or users we do not have permission to see + if api_message == GITHUB_NONEXISTENT_USER_MESSAGE: + log.debug(f"No GitHub user found named '{github_username}'") + return + else: + log.error(f"GitHub API request for '{github_username}' failed with message: {api_message}") + return [] # No October PRs were found due to error + + if jsonresp["total_count"] == 0: + # Short circuit if there aren't any PRs + log.info(f"No October PRs found for GitHub user: '{github_username}'") + return [] + + logging.info(f"Found {len(jsonresp['items'])} Hacktoberfest PRs for GitHub user: '{github_username}'") + outlist = [] # list of pr information dicts that will get returned + oct3 = datetime(int(CURRENT_YEAR), 10, 3, 23, 59, 59, tzinfo=None) + hackto_topics = {} # cache whether each repo has the appropriate topic (bool values) + for item in jsonresp["items"]: + shortname = self._get_shortname(item["repository_url"]) + itemdict = { + "repo_url": f"https://www.github.com/{shortname}", + "repo_shortname": shortname, + "created_at": datetime.strptime( + item["created_at"], "%Y-%m-%dT%H:%M:%SZ" + ), + "number": item["number"] + } + + # If the PR has 'invalid' or 'spam' labels, the PR must be + # either merged or approved for it to be included + if self._has_label(item, ["invalid", "spam"]): + if not await self._is_accepted(itemdict): + continue + + # PRs before oct 3 no need to check for topics + # continue the loop if 'hacktoberfest-accepted' is labelled then + # there is no need to check for its topics + if itemdict["created_at"] < oct3: + outlist.append(itemdict) + continue + + # Checking PR's labels for "hacktoberfest-accepted" + if self._has_label(item, "hacktoberfest-accepted"): + outlist.append(itemdict) + continue + + # No need to query GitHub if repo topics are fetched before already + if hackto_topics.get(shortname): + outlist.append(itemdict) + continue + # Fetch topics for the PR's repo + topics_query_url = f"https://api.github.com/repos/{shortname}/topics" + log.debug(f"Fetching repo topics for {shortname} with url: {topics_query_url}") + jsonresp2 = await self._fetch_url(topics_query_url, GITHUB_TOPICS_ACCEPT_HEADER) + if jsonresp2.get("names") is None: + log.error(f"Error fetching topics for {shortname}: {jsonresp2['message']}") + continue # Assume the repo doesn't have the `hacktoberfest` topic if API request errored + + # PRs after oct 3 that doesn't have 'hacktoberfest-accepted' label + # must be in repo with 'hacktoberfest' topic + if "hacktoberfest" in jsonresp2["names"]: + hackto_topics[shortname] = True # Cache result in the dict for later use if needed + outlist.append(itemdict) + return outlist + + async def _fetch_url(self, url: str, headers: dict, params: dict) -> dict: + """Retrieve API response from URL.""" + async with self.bot.http_session.get(url, headers=headers, params=params) as resp: + return await resp.json() + + @staticmethod + def _has_label(pr: dict, labels: Union[list[str], str]) -> bool: + """ + Check if a PR has label 'labels'. + + 'labels' can be a string or a list of strings, if it's a list of strings + it will return true if any of the labels match. + """ + if not pr.get("labels"): # if PR has no labels + return False + if isinstance(labels, str) and any(label["name"].casefold() == labels for label in pr["labels"]): + return True + for item in labels: + if any(label["name"].casefold() == item for label in pr["labels"]): + return True + return False + + async def _is_accepted(self, pr: dict) -> bool: + """Check if a PR is merged, approved, or labelled hacktoberfest-accepted.""" + # checking for merge status + query_url = f"https://api.github.com/repos/{pr['repo_shortname']}/pulls/{pr['number']}" + jsonresp = await self._fetch_url(query_url, REQUEST_HEADERS) + + if message := jsonresp.get("message"): + log.error(f"Error fetching PR stats for #{pr['number']} in repo {pr['repo_shortname']}:\n{message}") + return False + + if jsonresp.get("merged"): + return True + + # checking for the label, using `jsonresp` which has the label information + if self._has_label(jsonresp, "hacktoberfest-accepted"): + return True + + # checking approval + query_url += "/reviews" + jsonresp2 = await self._fetch_url(query_url, REQUEST_HEADERS) + if isinstance(jsonresp2, dict): + # if API request is unsuccessful it will be a dict with the error in 'message' + log.error( + f"Error fetching PR reviews for #{pr['number']} in repo {pr['repo_shortname']}:\n" + f"{jsonresp2['message']}" + ) + return False + # if it is successful it will be a list instead of a dict + if len(jsonresp2) == 0: # if PR has no reviews + return False + + # loop through reviews and check for approval + for item in jsonresp2: + if item.get("status") == "APPROVED": + return True + return False + + @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/sir-lancebot" + | + V + "python-discord/sir-lancebot" + """ + exp = r"https?:\/\/api.github.com\/repos\/([/\-\_\.\w]+)" + return re.findall(exp, in_url)[0] + + async def _categorize_prs(self, prs: list[dict]) -> tuple: + """ + Categorize PRs into 'in_review' and 'accepted' and returns as a tuple. + + PRs created less than 14 days ago are 'in_review', PRs that are not + are 'accepted' (after 14 days review period). + + PRs that are accepted must either be merged, approved, or labelled + 'hacktoberfest-accepted. + """ + now = datetime.now() + oct3 = datetime(CURRENT_YEAR, 10, 3, 23, 59, 59, tzinfo=None) + in_review = [] + accepted = [] + for pr in prs: + if (pr["created_at"] + timedelta(REVIEW_DAYS)) > now: + in_review.append(pr) + elif (pr["created_at"] <= oct3) or await self._is_accepted(pr): + accepted.append(pr) + + return in_review, accepted + + @staticmethod + def _build_prs_string(prs: list[tuple], user: str) -> str: + """ + Builds a discord embed compatible string for a list of PRs. + + Repository name with the link to pull requests authored by 'user' for + each PR. + """ + base_url = "https://www.github.com/" + str_list = [] + repo_list = [pr["repo_shortname"] for pr in prs] + prs_list = Counter(repo_list).most_common(5) # get first 5 counted PRs + more = len(prs) - sum(i[1] for i in prs_list) + + for pr in prs_list: + # for example: https://www.github.com/python-discord/bot/pulls/octocat + # will display pull requests authored by octocat. + # pr[1] is the number of PRs to the repo + string = f"{pr[1]} to [{pr[0]}]({base_url}{pr[0]}/pulls/{user})" + str_list.append(string) + if more: + str_list.append(f"...and {more} more") + + return "\n".join(str_list) + + @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[str, str]: + """Return stringified Message author ID and mentionable string from commands.Context.""" + author_id = str(ctx.author.id) + author_mention = ctx.author.mention + + return author_id, author_mention + + +def setup(bot: Bot) -> None: + """Load the Hacktober Stats Cog.""" + bot.add_cog(HacktoberStats(bot)) diff --git a/bot/exts/events/hacktoberfest/timeleft.py b/bot/exts/events/hacktoberfest/timeleft.py new file mode 100644 index 00000000..55109599 --- /dev/null +++ b/bot/exts/events/hacktoberfest/timeleft.py @@ -0,0 +1,67 @@ +import logging +from datetime import datetime + +from discord.ext import commands + +from bot.bot import Bot + +log = logging.getLogger(__name__) + + +class TimeLeft(commands.Cog): + """A Cog that tells you how long left until Hacktober is over!""" + + def in_hacktober(self) -> bool: + """Return True if the current time is within Hacktoberfest.""" + _, end, start = self.load_date() + + now = datetime.utcnow() + + return start <= now <= end + + @staticmethod + def load_date() -> tuple[datetime, 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, 12) # November 1st 12:00 (UTC-12:00) + start = datetime(year, 9, 30, 10) # September 30th 10:00 (UTC+14:00) + 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. + + This factors in that Hacktoberfest starts when it is October anywhere in the world + and ends with the same rules. It treats the start as UTC+14:00 and the end as + UTC-12. + """ + now, end, start = self.load_date() + diff = end - now + days, seconds = diff.days, diff.seconds + if self.in_hacktober(): + minutes = seconds // 60 + hours, minutes = divmod(minutes, 60) + + await ctx.send( + f"There are {days} days, {hours} hours and {minutes}" + f" 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: Bot) -> None: + """Load the Time Left Cog.""" + bot.add_cog(TimeLeft()) diff --git a/bot/exts/halloween/hacktober-issue-finder.py b/bot/exts/halloween/hacktober-issue-finder.py deleted file mode 100644 index e3053851..00000000 --- a/bot/exts/halloween/hacktober-issue-finder.py +++ /dev/null @@ -1,117 +0,0 @@ -import datetime -import logging -import random -from typing import Optional - -import discord -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Month, Tokens -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" - -REQUEST_HEADERS = { - "User-Agent": "Python Discord Hacktoberbot", - "Accept": "application / vnd.github.v3 + json" -} -if GITHUB_TOKEN := Tokens.github: - REQUEST_HEADERS["Authorization"] = f"token {GITHUB_TOKEN}" - - -class HacktoberIssues(commands.Cog): - """Find a random hacktober python issue on GitHub.""" - - def __init__(self, bot: 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. - """ - async 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 - - 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 self.bot.http_session.get(url, headers=REQUEST_HEADERS) as response: - if response.status != 200: - log.error(f"expected 200 status (got {response.status}) by the GitHub api.") - await ctx.send( - f"ERROR: expected 200 status (got {response.status}) by the GitHub api.\n" - f"{await response.text()}" - ) - return None - data = await response.json() - - if len(data["items"]) == 0: - log.error(f"no issues returned by GitHub API, with url: {response.url}") - await ctx.send(f"ERROR: no issues returned by 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[:500] + "..." if len(body) > 500 else 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: Bot) -> None: - """Load the HacktoberIssue finder.""" - bot.add_cog(HacktoberIssues(bot)) diff --git a/bot/exts/halloween/hacktoberstats.py b/bot/exts/halloween/hacktoberstats.py deleted file mode 100644 index 72067dbe..00000000 --- a/bot/exts/halloween/hacktoberstats.py +++ /dev/null @@ -1,437 +0,0 @@ -import logging -import random -import re -from collections import Counter -from datetime import datetime, timedelta -from typing import Optional, Union -from urllib.parse import quote_plus - -import discord -from async_rediscache import RedisCache -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Colours, Month, NEGATIVE_REPLIES, Tokens -from bot.utils.decorators import in_month - -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 -REVIEW_DAYS = 14 # number of days needed after PR can be mature - -REQUEST_HEADERS = {"User-Agent": "Python Discord Hacktoberbot"} -# using repo topics API during preview period requires an accept header -GITHUB_TOPICS_ACCEPT_HEADER = {"Accept": "application/vnd.github.mercy-preview+json"} -if GITHUB_TOKEN := Tokens.github: - REQUEST_HEADERS["Authorization"] = f"token {GITHUB_TOKEN}" - GITHUB_TOPICS_ACCEPT_HEADER["Authorization"] = f"token {GITHUB_TOKEN}" - -GITHUB_NONEXISTENT_USER_MESSAGE = ( - "The listed users cannot be searched either because the users do not exist " - "or you do not have permission to view the users." -) - - -class HacktoberStats(commands.Cog): - """Hacktoberfest statistics Cog.""" - - # Stores mapping of user IDs and GitHub usernames - linked_accounts = RedisCache() - - def __init__(self, bot: Bot): - self.bot = bot - - @in_month(Month.SEPTEMBER, Month.OCTOBER, Month.NOVEMBER) - @commands.group(name="hacktoberstats", aliases=("hackstats",), invoke_without_command=True) - 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 = self._author_mention_from_context(ctx) - - if await self.linked_accounts.contains(author_id): - github_username = await self.linked_accounts.get(author_id) - 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```\n{ctx.prefix}hackstats link github_username\n```\n" - f"Or query GitHub stats directly using:\n```\n{ctx.prefix}hackstats github_username\n```" - ) - await ctx.send(msg) - return - - await self.get_stats(ctx, github_username) - - @in_month(Month.SEPTEMBER, Month.OCTOBER, Month.NOVEMBER) - @hacktoberstats_group.command(name="link") - 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 in Redis: User ID => GitHub Username. - """ - author_id, author_mention = self._author_mention_from_context(ctx) - if github_username: - if await self.linked_accounts.contains(author_id): - old_username = await self.linked_accounts.get(author_id) - log.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: - log.info(f"{author_id} has added a github link to '{github_username}'") - await ctx.send(f"{author_mention}, your GitHub username has been added") - - await self.linked_accounts.set(author_id, github_username) - else: - log.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.SEPTEMBER, Month.OCTOBER, Month.NOVEMBER) - @hacktoberstats_group.command(name="unlink") - async def unlink_user(self, ctx: commands.Context) -> None: - """Remove the invoking user's account link from the log.""" - author_id, author_mention = self._author_mention_from_context(ctx) - - stored_user = await 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") - - 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 an 'invalid' or 'spam' label are ignored - - For PRs created after October 3rd, they have to be in a repository that has a - 'hacktoberfest' topic, unless the PR is labelled 'hacktoberfest-accepted' for it - to count. - - 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 is None: # Will be None if the user was not found - await ctx.send( - embed=discord.Embed( - title=random.choice(NEGATIVE_REPLIES), - description=f"GitHub user `{github_username}` was not found.", - colour=discord.Colour.red() - ) - ) - return - - if prs: - stats_embed = await self.build_embed(github_username, prs) - await ctx.send("Here are some stats!", embed=stats_embed) - else: - await ctx.send(f"No valid Hacktoberfest PRs found for '{github_username}'") - - async 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}'") - in_review, accepted = await self._categorize_prs(prs) - - n = len(accepted) + len(in_review) # Total number of PRs - if n >= PRS_FOR_SHIRT: - shirtstr = f"**{github_username} is eligible for a T-shirt or a tree!**" - elif n == PRS_FOR_SHIRT - 1: - shirtstr = f"**{github_username} is 1 PR away from a T-shirt or a tree!**" - else: - shirtstr = f"**{github_username} is {PRS_FOR_SHIRT - n} PRs away from a T-shirt or a tree!**" - - stats_embed = discord.Embed( - title=f"{github_username}'s Hacktoberfest", - color=Colours.purple, - description=( - f"{github_username} has made {n} valid " - f"{self._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://avatars1.githubusercontent.com/u/35706162?s=200&v=4" - ) - - # This will handle when no PRs in_review or accepted - review_str = self._build_prs_string(in_review, github_username) or "None" - accepted_str = self._build_prs_string(accepted, github_username) or "None" - stats_embed.add_field( - name=":clock1: In Review", - value=review_str - ) - stats_embed.add_field( - name=":tada: Accepted", - value=accepted_str - ) - - logging.info(f"Hacktoberfest PR built for GitHub user '{github_username}'") - return stats_embed - - async def get_october_prs(self, github_username: str) -> Optional[list[dict]]: - """ - Query GitHub's API for PRs created during the month of October by github_username. - - PRs with an 'invalid' or 'spam' label are ignored unless it is merged or approved - - For PRs created after October 3rd, they have to be in a repository that has a - 'hacktoberfest' topic, unless the PR is labelled 'hacktoberfest-accepted' for it - to count. - - 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/sir-lancebot") - "created_at": datetime.datetime - "number": int - } - - Otherwise, return empty list. - None will be returned when the GitHub user was not found. - """ - log.info(f"Fetching Hacktoberfest Stats for GitHub user: '{github_username}'") - base_url = "https://api.github.com/search/issues" - action_type = "pr" - is_query = "public" - not_query = "draft" - date_range = f"{CURRENT_YEAR}-09-30T10:00Z..{CURRENT_YEAR}-11-01T12:00Z" - per_page = "300" - query_params = ( - f"+type:{action_type}" - f"+is:{is_query}" - f"+author:{quote_plus(github_username)}" - f"+-is:{not_query}" - f"+created:{date_range}" - f"&per_page={per_page}" - ) - - log.debug(f"GitHub query parameters generated: {query_params}") - - jsonresp = await self._fetch_url(base_url, REQUEST_HEADERS, {"q": query_params}) - if "message" in jsonresp: - # One of the parameters is invalid, short circuit for now - api_message = jsonresp["errors"][0]["message"] - - # Ignore logging non-existent users or users we do not have permission to see - if api_message == GITHUB_NONEXISTENT_USER_MESSAGE: - log.debug(f"No GitHub user found named '{github_username}'") - return - else: - log.error(f"GitHub API request for '{github_username}' failed with message: {api_message}") - return [] # No October PRs were found due to error - - if jsonresp["total_count"] == 0: - # Short circuit if there aren't any PRs - log.info(f"No October PRs found for GitHub user: '{github_username}'") - return [] - - logging.info(f"Found {len(jsonresp['items'])} Hacktoberfest PRs for GitHub user: '{github_username}'") - outlist = [] # list of pr information dicts that will get returned - oct3 = datetime(int(CURRENT_YEAR), 10, 3, 23, 59, 59, tzinfo=None) - hackto_topics = {} # cache whether each repo has the appropriate topic (bool values) - for item in jsonresp["items"]: - shortname = self._get_shortname(item["repository_url"]) - itemdict = { - "repo_url": f"https://www.github.com/{shortname}", - "repo_shortname": shortname, - "created_at": datetime.strptime( - item["created_at"], "%Y-%m-%dT%H:%M:%SZ" - ), - "number": item["number"] - } - - # If the PR has 'invalid' or 'spam' labels, the PR must be - # either merged or approved for it to be included - if self._has_label(item, ["invalid", "spam"]): - if not await self._is_accepted(itemdict): - continue - - # PRs before oct 3 no need to check for topics - # continue the loop if 'hacktoberfest-accepted' is labelled then - # there is no need to check for its topics - if itemdict["created_at"] < oct3: - outlist.append(itemdict) - continue - - # Checking PR's labels for "hacktoberfest-accepted" - if self._has_label(item, "hacktoberfest-accepted"): - outlist.append(itemdict) - continue - - # No need to query GitHub if repo topics are fetched before already - if hackto_topics.get(shortname): - outlist.append(itemdict) - continue - # Fetch topics for the PR's repo - topics_query_url = f"https://api.github.com/repos/{shortname}/topics" - log.debug(f"Fetching repo topics for {shortname} with url: {topics_query_url}") - jsonresp2 = await self._fetch_url(topics_query_url, GITHUB_TOPICS_ACCEPT_HEADER) - if jsonresp2.get("names") is None: - log.error(f"Error fetching topics for {shortname}: {jsonresp2['message']}") - continue # Assume the repo doesn't have the `hacktoberfest` topic if API request errored - - # PRs after oct 3 that doesn't have 'hacktoberfest-accepted' label - # must be in repo with 'hacktoberfest' topic - if "hacktoberfest" in jsonresp2["names"]: - hackto_topics[shortname] = True # Cache result in the dict for later use if needed - outlist.append(itemdict) - return outlist - - async def _fetch_url(self, url: str, headers: dict, params: dict) -> dict: - """Retrieve API response from URL.""" - async with self.bot.http_session.get(url, headers=headers, params=params) as resp: - return await resp.json() - - @staticmethod - def _has_label(pr: dict, labels: Union[list[str], str]) -> bool: - """ - Check if a PR has label 'labels'. - - 'labels' can be a string or a list of strings, if it's a list of strings - it will return true if any of the labels match. - """ - if not pr.get("labels"): # if PR has no labels - return False - if isinstance(labels, str) and any(label["name"].casefold() == labels for label in pr["labels"]): - return True - for item in labels: - if any(label["name"].casefold() == item for label in pr["labels"]): - return True - return False - - async def _is_accepted(self, pr: dict) -> bool: - """Check if a PR is merged, approved, or labelled hacktoberfest-accepted.""" - # checking for merge status - query_url = f"https://api.github.com/repos/{pr['repo_shortname']}/pulls/{pr['number']}" - jsonresp = await self._fetch_url(query_url, REQUEST_HEADERS) - - if message := jsonresp.get("message"): - log.error(f"Error fetching PR stats for #{pr['number']} in repo {pr['repo_shortname']}:\n{message}") - return False - - if jsonresp.get("merged"): - return True - - # checking for the label, using `jsonresp` which has the label information - if self._has_label(jsonresp, "hacktoberfest-accepted"): - return True - - # checking approval - query_url += "/reviews" - jsonresp2 = await self._fetch_url(query_url, REQUEST_HEADERS) - if isinstance(jsonresp2, dict): - # if API request is unsuccessful it will be a dict with the error in 'message' - log.error( - f"Error fetching PR reviews for #{pr['number']} in repo {pr['repo_shortname']}:\n" - f"{jsonresp2['message']}" - ) - return False - # if it is successful it will be a list instead of a dict - if len(jsonresp2) == 0: # if PR has no reviews - return False - - # loop through reviews and check for approval - for item in jsonresp2: - if item.get("status") == "APPROVED": - return True - return False - - @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/sir-lancebot" - | - V - "python-discord/sir-lancebot" - """ - exp = r"https?:\/\/api.github.com\/repos\/([/\-\_\.\w]+)" - return re.findall(exp, in_url)[0] - - async def _categorize_prs(self, prs: list[dict]) -> tuple: - """ - Categorize PRs into 'in_review' and 'accepted' and returns as a tuple. - - PRs created less than 14 days ago are 'in_review', PRs that are not - are 'accepted' (after 14 days review period). - - PRs that are accepted must either be merged, approved, or labelled - 'hacktoberfest-accepted. - """ - now = datetime.now() - oct3 = datetime(CURRENT_YEAR, 10, 3, 23, 59, 59, tzinfo=None) - in_review = [] - accepted = [] - for pr in prs: - if (pr["created_at"] + timedelta(REVIEW_DAYS)) > now: - in_review.append(pr) - elif (pr["created_at"] <= oct3) or await self._is_accepted(pr): - accepted.append(pr) - - return in_review, accepted - - @staticmethod - def _build_prs_string(prs: list[tuple], user: str) -> str: - """ - Builds a discord embed compatible string for a list of PRs. - - Repository name with the link to pull requests authored by 'user' for - each PR. - """ - base_url = "https://www.github.com/" - str_list = [] - repo_list = [pr["repo_shortname"] for pr in prs] - prs_list = Counter(repo_list).most_common(5) # get first 5 counted PRs - more = len(prs) - sum(i[1] for i in prs_list) - - for pr in prs_list: - # for example: https://www.github.com/python-discord/bot/pulls/octocat - # will display pull requests authored by octocat. - # pr[1] is the number of PRs to the repo - string = f"{pr[1]} to [{pr[0]}]({base_url}{pr[0]}/pulls/{user})" - str_list.append(string) - if more: - str_list.append(f"...and {more} more") - - return "\n".join(str_list) - - @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[str, str]: - """Return stringified Message author ID and mentionable string from commands.Context.""" - author_id = str(ctx.author.id) - author_mention = ctx.author.mention - - return author_id, author_mention - - -def setup(bot: Bot) -> None: - """Load the Hacktober Stats Cog.""" - bot.add_cog(HacktoberStats(bot)) diff --git a/bot/exts/halloween/timeleft.py b/bot/exts/halloween/timeleft.py deleted file mode 100644 index 55109599..00000000 --- a/bot/exts/halloween/timeleft.py +++ /dev/null @@ -1,67 +0,0 @@ -import logging -from datetime import datetime - -from discord.ext import commands - -from bot.bot import Bot - -log = logging.getLogger(__name__) - - -class TimeLeft(commands.Cog): - """A Cog that tells you how long left until Hacktober is over!""" - - def in_hacktober(self) -> bool: - """Return True if the current time is within Hacktoberfest.""" - _, end, start = self.load_date() - - now = datetime.utcnow() - - return start <= now <= end - - @staticmethod - def load_date() -> tuple[datetime, 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, 12) # November 1st 12:00 (UTC-12:00) - start = datetime(year, 9, 30, 10) # September 30th 10:00 (UTC+14:00) - 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. - - This factors in that Hacktoberfest starts when it is October anywhere in the world - and ends with the same rules. It treats the start as UTC+14:00 and the end as - UTC-12. - """ - now, end, start = self.load_date() - diff = end - now - days, seconds = diff.days, diff.seconds - if self.in_hacktober(): - minutes = seconds // 60 - hours, minutes = divmod(minutes, 60) - - await ctx.send( - f"There are {days} days, {hours} hours and {minutes}" - f" 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: Bot) -> None: - """Load the Time Left Cog.""" - bot.add_cog(TimeLeft()) diff --git a/bot/resources/advent_of_code/about.json b/bot/resources/advent_of_code/about.json deleted file mode 100644 index dd0fe59a..00000000 --- a/bot/resources/advent_of_code/about.json +++ /dev/null @@ -1,27 +0,0 @@ -[ - { - "name": "What is Advent of Code?", - "value": "Advent of Code (AoC) is a series of small programming puzzles for a variety of skill levels, run every year during the month of December.\n\nThey are self-contained and are just as appropriate for an expert who wants to stay sharp as they are for a beginner who is just learning to code. Each puzzle calls upon different skills and has two parts that build on a theme.", - "inline": false - }, - { - "name": "How do I sign up?", - "value": "Sign up with one of these services:", - "inline": true - }, - { - "name": "Auth Services", - "value": "GitHub\nGoogle\nTwitter\nReddit", - "inline": true - }, - { - "name": "How does scoring work?", - "value": "For the [global leaderboard](https://adventofcode.com/leaderboard), the first person to get a star first gets 100 points, the second person gets 99 points, and so on down to 1 point at 100th place.\n\nFor private leaderboards, the first person to get a star gets N points, where N is the number of people on the leaderboard. The second person to get the star gets N-1 points and so on and so forth.", - "inline": false - }, - { - "name": "Join our private leaderboard!", - "value": "Come join the Python Discord private leaderboard and compete against other people in the community! Get the join code using `.aoc join` and visit the [private leaderboard page](https://adventofcode.com/leaderboard/private) to join our leaderboard.", - "inline": false - } -] diff --git a/bot/resources/events/advent_of_code/about.json b/bot/resources/events/advent_of_code/about.json new file mode 100644 index 00000000..dd0fe59a --- /dev/null +++ b/bot/resources/events/advent_of_code/about.json @@ -0,0 +1,27 @@ +[ + { + "name": "What is Advent of Code?", + "value": "Advent of Code (AoC) is a series of small programming puzzles for a variety of skill levels, run every year during the month of December.\n\nThey are self-contained and are just as appropriate for an expert who wants to stay sharp as they are for a beginner who is just learning to code. Each puzzle calls upon different skills and has two parts that build on a theme.", + "inline": false + }, + { + "name": "How do I sign up?", + "value": "Sign up with one of these services:", + "inline": true + }, + { + "name": "Auth Services", + "value": "GitHub\nGoogle\nTwitter\nReddit", + "inline": true + }, + { + "name": "How does scoring work?", + "value": "For the [global leaderboard](https://adventofcode.com/leaderboard), the first person to get a star first gets 100 points, the second person gets 99 points, and so on down to 1 point at 100th place.\n\nFor private leaderboards, the first person to get a star gets N points, where N is the number of people on the leaderboard. The second person to get the star gets N-1 points and so on and so forth.", + "inline": false + }, + { + "name": "Join our private leaderboard!", + "value": "Come join the Python Discord private leaderboard and compete against other people in the community! Get the join code using `.aoc join` and visit the [private leaderboard page](https://adventofcode.com/leaderboard/private) to join our leaderboard.", + "inline": false + } +] -- cgit v1.2.3 From 2780043e6ddd5dbee82b62d85289f0518613ce7b Mon Sep 17 00:00:00 2001 From: Janine vN Date: Sat, 4 Sep 2021 23:22:12 -0400 Subject: Move Easter to Holidays Folder This moves the easter seasonal features into a more cohesive holidays/easter folder. Additionally, this splits out earth day into its own holiday folder. --- bot/exts/easter/__init__.py | 0 bot/exts/easter/april_fools_vids.py | 30 ---- bot/exts/easter/bunny_name_generator.py | 94 ----------- bot/exts/easter/earth_photos.py | 66 -------- bot/exts/easter/easter_riddle.py | 112 ------------- bot/exts/easter/egg_decorating.py | 119 -------------- bot/exts/easter/egg_facts.py | 55 ------- bot/exts/easter/egghead_quiz.py | 118 -------------- bot/exts/easter/save_the_planet.py | 25 --- bot/exts/easter/traditions.py | 28 ---- bot/exts/halloween/__init__.py | 0 bot/exts/holidays/__init__.py | 0 bot/exts/holidays/earth_day/__init__.py | 0 bot/exts/holidays/earth_day/save_the_planet.py | 25 +++ bot/exts/holidays/easter/__init__.py | 0 bot/exts/holidays/easter/april_fools_vids.py | 30 ++++ bot/exts/holidays/easter/bunny_name_generator.py | 94 +++++++++++ bot/exts/holidays/easter/earth_photos.py | 66 ++++++++ bot/exts/holidays/easter/easter_riddle.py | 112 +++++++++++++ bot/exts/holidays/easter/egg_decorating.py | 119 ++++++++++++++ bot/exts/holidays/easter/egg_facts.py | 55 +++++++ bot/exts/holidays/easter/egghead_quiz.py | 118 ++++++++++++++ bot/exts/holidays/easter/traditions.py | 28 ++++ bot/exts/pride/__init__.py | 0 bot/resources/easter/april_fools_vids.json | 130 --------------- bot/resources/easter/bunny_names.json | 29 ---- bot/resources/easter/chocolate_bunny.png | Bin 7789 -> 0 bytes bot/resources/easter/easter_egg_facts.json | 17 -- bot/resources/easter/easter_eggs/design1.png | Bin 3996 -> 0 bytes bot/resources/easter/easter_eggs/design2.png | Bin 3918 -> 0 bytes bot/resources/easter/easter_eggs/design3.png | Bin 3349 -> 0 bytes bot/resources/easter/easter_eggs/design4.png | Bin 3355 -> 0 bytes bot/resources/easter/easter_eggs/design5.png | Bin 3054 -> 0 bytes bot/resources/easter/easter_eggs/design6.png | Bin 4758 -> 0 bytes bot/resources/easter/easter_riddle.json | 74 --------- bot/resources/easter/egghead_questions.json | 181 --------------------- bot/resources/easter/save_the_planet.json | 77 --------- bot/resources/easter/traditions.json | 13 -- .../holidays/earth_day/save_the_planet.json | 77 +++++++++ .../holidays/easter/april_fools_vids.json | 130 +++++++++++++++ bot/resources/holidays/easter/bunny_names.json | 29 ++++ bot/resources/holidays/easter/chocolate_bunny.png | Bin 0 -> 7789 bytes .../holidays/easter/easter_egg_facts.json | 17 ++ .../holidays/easter/easter_eggs/design1.png | Bin 0 -> 3996 bytes .../holidays/easter/easter_eggs/design2.png | Bin 0 -> 3918 bytes .../holidays/easter/easter_eggs/design3.png | Bin 0 -> 3349 bytes .../holidays/easter/easter_eggs/design4.png | Bin 0 -> 3355 bytes .../holidays/easter/easter_eggs/design5.png | Bin 0 -> 3054 bytes .../holidays/easter/easter_eggs/design6.png | Bin 0 -> 4758 bytes bot/resources/holidays/easter/easter_riddle.json | 74 +++++++++ .../holidays/easter/egghead_questions.json | 181 +++++++++++++++++++++ bot/resources/holidays/easter/traditions.json | 13 ++ 52 files changed, 1168 insertions(+), 1168 deletions(-) delete mode 100644 bot/exts/easter/__init__.py delete mode 100644 bot/exts/easter/april_fools_vids.py delete mode 100644 bot/exts/easter/bunny_name_generator.py delete mode 100644 bot/exts/easter/earth_photos.py delete mode 100644 bot/exts/easter/easter_riddle.py delete mode 100644 bot/exts/easter/egg_decorating.py delete mode 100644 bot/exts/easter/egg_facts.py delete mode 100644 bot/exts/easter/egghead_quiz.py delete mode 100644 bot/exts/easter/save_the_planet.py delete mode 100644 bot/exts/easter/traditions.py delete mode 100644 bot/exts/halloween/__init__.py create mode 100644 bot/exts/holidays/__init__.py create mode 100644 bot/exts/holidays/earth_day/__init__.py create mode 100644 bot/exts/holidays/earth_day/save_the_planet.py create mode 100644 bot/exts/holidays/easter/__init__.py create mode 100644 bot/exts/holidays/easter/april_fools_vids.py create mode 100644 bot/exts/holidays/easter/bunny_name_generator.py create mode 100644 bot/exts/holidays/easter/earth_photos.py create mode 100644 bot/exts/holidays/easter/easter_riddle.py create mode 100644 bot/exts/holidays/easter/egg_decorating.py create mode 100644 bot/exts/holidays/easter/egg_facts.py create mode 100644 bot/exts/holidays/easter/egghead_quiz.py create mode 100644 bot/exts/holidays/easter/traditions.py delete mode 100644 bot/exts/pride/__init__.py delete mode 100644 bot/resources/easter/april_fools_vids.json delete mode 100644 bot/resources/easter/bunny_names.json delete mode 100644 bot/resources/easter/chocolate_bunny.png delete mode 100644 bot/resources/easter/easter_egg_facts.json delete mode 100644 bot/resources/easter/easter_eggs/design1.png delete mode 100644 bot/resources/easter/easter_eggs/design2.png delete mode 100644 bot/resources/easter/easter_eggs/design3.png delete mode 100644 bot/resources/easter/easter_eggs/design4.png delete mode 100644 bot/resources/easter/easter_eggs/design5.png delete mode 100644 bot/resources/easter/easter_eggs/design6.png delete mode 100644 bot/resources/easter/easter_riddle.json delete mode 100644 bot/resources/easter/egghead_questions.json delete mode 100644 bot/resources/easter/save_the_planet.json delete mode 100644 bot/resources/easter/traditions.json create mode 100644 bot/resources/holidays/earth_day/save_the_planet.json create mode 100644 bot/resources/holidays/easter/april_fools_vids.json create mode 100644 bot/resources/holidays/easter/bunny_names.json create mode 100644 bot/resources/holidays/easter/chocolate_bunny.png create mode 100644 bot/resources/holidays/easter/easter_egg_facts.json create mode 100644 bot/resources/holidays/easter/easter_eggs/design1.png create mode 100644 bot/resources/holidays/easter/easter_eggs/design2.png create mode 100644 bot/resources/holidays/easter/easter_eggs/design3.png create mode 100644 bot/resources/holidays/easter/easter_eggs/design4.png create mode 100644 bot/resources/holidays/easter/easter_eggs/design5.png create mode 100644 bot/resources/holidays/easter/easter_eggs/design6.png create mode 100644 bot/resources/holidays/easter/easter_riddle.json create mode 100644 bot/resources/holidays/easter/egghead_questions.json create mode 100644 bot/resources/holidays/easter/traditions.json (limited to 'bot') diff --git a/bot/exts/easter/__init__.py b/bot/exts/easter/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/bot/exts/easter/april_fools_vids.py b/bot/exts/easter/april_fools_vids.py deleted file mode 100644 index 5ef40704..00000000 --- a/bot/exts/easter/april_fools_vids.py +++ /dev/null @@ -1,30 +0,0 @@ -import logging -import random -from json import loads -from pathlib import Path - -from discord.ext import commands - -from bot.bot import Bot - -log = logging.getLogger(__name__) - -ALL_VIDS = loads(Path("bot/resources/easter/april_fools_vids.json").read_text("utf-8")) - - -class AprilFoolVideos(commands.Cog): - """A cog for April Fools' that gets a random April Fools' video from Youtube.""" - - @commands.command(name="fool") - async def april_fools(self, ctx: commands.Context) -> None: - """Get a random April Fools' video from Youtube.""" - video = random.choice(ALL_VIDS) - - channel, url = video["channel"], video["url"] - - await ctx.send(f"Check out this April Fools' video by {channel}.\n\n{url}") - - -def setup(bot: Bot) -> None: - """Load the April Fools' Cog.""" - bot.add_cog(AprilFoolVideos()) diff --git a/bot/exts/easter/bunny_name_generator.py b/bot/exts/easter/bunny_name_generator.py deleted file mode 100644 index 4c3137de..00000000 --- a/bot/exts/easter/bunny_name_generator.py +++ /dev/null @@ -1,94 +0,0 @@ -import json -import logging -import random -import re -from pathlib import Path -from typing import Optional - -from discord.ext import commands - -from bot.bot import Bot - -log = logging.getLogger(__name__) - -BUNNY_NAMES = json.loads(Path("bot/resources/easter/bunny_names.json").read_text("utf8")) - - -class BunnyNameGenerator(commands.Cog): - """Generate a random bunny name, or bunnify your Discord username!""" - - @staticmethod - def find_separators(displayname: str) -> Optional[list[str]]: - """Check if Discord name contains spaces so we can bunnify an individual word in the name.""" - new_name = re.split(r"[_.\s]", displayname) - if displayname not in new_name: - return new_name - return None - - @staticmethod - def find_vowels(displayname: str) -> Optional[str]: - """ - Finds vowels in the user's display name. - - If the Discord name contains a vowel and the letter y, it will match one or more of these patterns. - - Only the most recently matched pattern will apply the changes. - """ - expressions = [ - ("a.+y", "patchy"), - ("e.+y", "ears"), - ("i.+y", "ditsy"), - ("o.+y", "oofy"), - ("u.+y", "uffy"), - ] - - for exp, vowel_sub in expressions: - new_name = re.sub(exp, vowel_sub, displayname) - if new_name != displayname: - return new_name - - @staticmethod - def append_name(displayname: str) -> str: - """Adds a suffix to the end of the Discord name.""" - extensions = ["foot", "ear", "nose", "tail"] - suffix = random.choice(extensions) - appended_name = displayname + suffix - - return appended_name - - @commands.command() - async def bunnyname(self, ctx: commands.Context) -> None: - """Picks a random bunny name from a JSON file.""" - await ctx.send(random.choice(BUNNY_NAMES["names"])) - - @commands.command() - async def bunnifyme(self, ctx: commands.Context) -> None: - """Gets your Discord username and bunnifies it.""" - username = ctx.author.display_name - - # If name contains spaces or other separators, get the individual words to randomly bunnify - spaces_in_name = self.find_separators(username) - - # If name contains vowels, see if it matches any of the patterns in this function - # If there are matches, the bunnified name is returned. - vowels_in_name = self.find_vowels(username) - - # Default if the checks above return None - unmatched_name = self.append_name(username) - - if spaces_in_name is not None: - replacements = ["Cotton", "Fluff", "Floof" "Bounce", "Snuffle", "Nibble", "Cuddle", "Velvetpaw", "Carrot"] - word_to_replace = random.choice(spaces_in_name) - substitute = random.choice(replacements) - bunnified_name = username.replace(word_to_replace, substitute) - elif vowels_in_name is not None: - bunnified_name = vowels_in_name - elif unmatched_name: - bunnified_name = unmatched_name - - await ctx.send(bunnified_name) - - -def setup(bot: Bot) -> None: - """Load the Bunny Name Generator Cog.""" - bot.add_cog(BunnyNameGenerator()) diff --git a/bot/exts/easter/earth_photos.py b/bot/exts/easter/earth_photos.py deleted file mode 100644 index f65790af..00000000 --- a/bot/exts/easter/earth_photos.py +++ /dev/null @@ -1,66 +0,0 @@ -import logging - -import discord -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Colours -from bot.constants import Tokens - -log = logging.getLogger(__name__) - -API_URL = "https://api.unsplash.com/photos/random" - - -class EarthPhotos(commands.Cog): - """This cog contains the command for earth photos.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @commands.command(aliases=("earth",)) - async def earth_photos(self, ctx: commands.Context) -> None: - """Returns a random photo of earth, sourced from Unsplash.""" - async with ctx.typing(): - async with self.bot.http_session.get( - API_URL, - params={"query": "planet_earth", "client_id": Tokens.unsplash_access_key} - ) as r: - jsondata = await r.json() - linksdata = jsondata.get("urls") - embedlink = linksdata.get("regular") - downloadlinksdata = jsondata.get("links") - userdata = jsondata.get("user") - username = userdata.get("name") - userlinks = userdata.get("links") - profile = userlinks.get("html") - # Referral flags - rf = "?utm_source=Sir%20Lancebot&utm_medium=referral" - async with self.bot.http_session.get( - downloadlinksdata.get("download_location"), - params={"client_id": Tokens.unsplash_access_key} - ) as _: - pass - - embed = discord.Embed( - title="Earth Photo", - description="A photo of Earth 🌎 from Unsplash.", - color=Colours.grass_green - ) - embed.set_image(url=embedlink) - embed.add_field( - name="Author", - value=( - f"Photo by [{username}]({profile}{rf}) " - f"on [Unsplash](https://unsplash.com{rf})." - ) - ) - await ctx.send(embed=embed) - - -def setup(bot: Bot) -> None: - """Load the Earth Photos cog.""" - if not Tokens.unsplash_access_key: - log.warning("No Unsplash access key found. Cog not loading.") - return - bot.add_cog(EarthPhotos(bot)) diff --git a/bot/exts/easter/easter_riddle.py b/bot/exts/easter/easter_riddle.py deleted file mode 100644 index 88b3be2f..00000000 --- a/bot/exts/easter/easter_riddle.py +++ /dev/null @@ -1,112 +0,0 @@ -import asyncio -import logging -import random -from json import loads -from pathlib import Path - -import discord -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Colours, NEGATIVE_REPLIES - -log = logging.getLogger(__name__) - -RIDDLE_QUESTIONS = loads(Path("bot/resources/easter/easter_riddle.json").read_text("utf8")) - -TIMELIMIT = 10 - - -class EasterRiddle(commands.Cog): - """This cog contains the command for the Easter quiz!""" - - def __init__(self, bot: Bot): - self.bot = bot - self.winners = set() - self.correct = "" - self.current_channel = None - - @commands.command(aliases=("riddlemethis", "riddleme")) - async def riddle(self, ctx: commands.Context) -> None: - """ - Gives a random riddle, then provides 2 hints at certain intervals before revealing the answer. - - The duration of the hint interval can be configured by changing the TIMELIMIT constant in this file. - """ - if self.current_channel: - await ctx.send(f"A riddle is already being solved in {self.current_channel.mention}!") - return - - # Don't let users start in a DM - if not ctx.guild: - await ctx.send( - embed=discord.Embed( - title=random.choice(NEGATIVE_REPLIES), - description="You can't start riddles in DMs", - colour=discord.Colour.red() - ) - ) - return - - self.current_channel = ctx.channel - - random_question = random.choice(RIDDLE_QUESTIONS) - question = random_question["question"] - hints = random_question["riddles"] - self.correct = random_question["correct_answer"] - - description = f"You have {TIMELIMIT} seconds before the first hint." - - riddle_embed = discord.Embed(title=question, description=description, colour=Colours.pink) - - await ctx.send(embed=riddle_embed) - await asyncio.sleep(TIMELIMIT) - - hint_embed = discord.Embed( - title=f"Here's a hint: {hints[0]}!", - colour=Colours.pink - ) - - await ctx.send(embed=hint_embed) - await asyncio.sleep(TIMELIMIT) - - hint_embed = discord.Embed( - title=f"Here's a hint: {hints[1]}!", - colour=Colours.pink - ) - - await ctx.send(embed=hint_embed) - await asyncio.sleep(TIMELIMIT) - - if self.winners: - win_list = " ".join(self.winners) - content = f"Well done {win_list} for getting it right!" - else: - content = "Nobody got it right..." - - answer_embed = discord.Embed( - title=f"The answer is: {self.correct}!", - colour=Colours.pink - ) - - await ctx.send(content, embed=answer_embed) - - self.winners.clear() - self.current_channel = None - - @commands.Cog.listener() - async def on_message(self, message: discord.Message) -> None: - """If a non-bot user enters a correct answer, their username gets added to self.winners.""" - if self.current_channel != message.channel: - return - - if self.bot.user == message.author: - return - - if message.content.lower() == self.correct.lower(): - self.winners.add(message.author.mention) - - -def setup(bot: Bot) -> None: - """Easter Riddle Cog load.""" - bot.add_cog(EasterRiddle(bot)) diff --git a/bot/exts/easter/egg_decorating.py b/bot/exts/easter/egg_decorating.py deleted file mode 100644 index fb5701c4..00000000 --- a/bot/exts/easter/egg_decorating.py +++ /dev/null @@ -1,119 +0,0 @@ -import json -import logging -import random -from contextlib import suppress -from io import BytesIO -from pathlib import Path -from typing import Optional, Union - -import discord -from PIL import Image -from discord.ext import commands - -from bot.bot import Bot -from bot.utils import helpers - -log = logging.getLogger(__name__) - -HTML_COLOURS = json.loads(Path("bot/resources/evergreen/html_colours.json").read_text("utf8")) - -XKCD_COLOURS = json.loads(Path("bot/resources/evergreen/xkcd_colours.json").read_text("utf8")) - -COLOURS = [ - (255, 0, 0, 255), (255, 128, 0, 255), (255, 255, 0, 255), (0, 255, 0, 255), - (0, 255, 255, 255), (0, 0, 255, 255), (255, 0, 255, 255), (128, 0, 128, 255) -] # Colours to be replaced - Red, Orange, Yellow, Green, Light Blue, Dark Blue, Pink, Purple - -IRREPLACEABLE = [ - (0, 0, 0, 0), (0, 0, 0, 255) -] # Colours that are meant to stay the same - Transparent and Black - - -class EggDecorating(commands.Cog): - """Decorate some easter eggs!""" - - @staticmethod - def replace_invalid(colour: str) -> Optional[int]: - """Attempts to match with HTML or XKCD colour names, returning the int value.""" - with suppress(KeyError): - return int(HTML_COLOURS[colour], 16) - with suppress(KeyError): - return int(XKCD_COLOURS[colour], 16) - return None - - @commands.command(aliases=("decorateegg",)) - async def eggdecorate( - self, ctx: commands.Context, *colours: Union[discord.Colour, str] - ) -> Optional[Image.Image]: - """ - Picks a random egg design and decorates it using the given colours. - - Colours are split by spaces, unless you wrap the colour name in double quotes. - Discord colour names, HTML colour names, XKCD colour names and hex values are accepted. - """ - if len(colours) < 2: - await ctx.send("You must include at least 2 colours!") - return - - invalid = [] - colours = list(colours) - for idx, colour in enumerate(colours): - if isinstance(colour, discord.Colour): - continue - value = self.replace_invalid(colour) - if value: - colours[idx] = discord.Colour(value) - else: - invalid.append(helpers.suppress_links(colour)) - - if len(invalid) > 1: - await ctx.send(f"Sorry, I don't know these colours: {' '.join(invalid)}") - return - elif len(invalid) == 1: - await ctx.send(f"Sorry, I don't know the colour {invalid[0]}!") - return - - async with ctx.typing(): - # Expand list to 8 colours - colours_n = len(colours) - if colours_n < 8: - q, r = divmod(8, colours_n) - colours = colours * q + colours[:r] - num = random.randint(1, 6) - im = Image.open(Path(f"bot/resources/easter/easter_eggs/design{num}.png")) - data = list(im.getdata()) - - replaceable = {x for x in data if x not in IRREPLACEABLE} - replaceable = sorted(replaceable, key=COLOURS.index) - - replacing_colours = {colour: colours[i] for i, colour in enumerate(replaceable)} - new_data = [] - for x in data: - if x in replacing_colours: - new_data.append((*replacing_colours[x].to_rgb(), 255)) - # Also ensures that the alpha channel has a value - else: - new_data.append(x) - new_im = Image.new(im.mode, im.size) - new_im.putdata(new_data) - - bufferedio = BytesIO() - new_im.save(bufferedio, format="PNG") - - bufferedio.seek(0) - - file = discord.File(bufferedio, filename="egg.png") # Creates file to be used in embed - embed = discord.Embed( - title="Your Colourful Easter Egg", - description="Here is your pretty little egg. Hope you like it!" - ) - embed.set_image(url="attachment://egg.png") - embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.display_avatar.url) - - await ctx.send(file=file, embed=embed) - return new_im - - -def setup(bot: Bot) -> None: - """Load the Egg decorating Cog.""" - bot.add_cog(EggDecorating()) diff --git a/bot/exts/easter/egg_facts.py b/bot/exts/easter/egg_facts.py deleted file mode 100644 index 486e735f..00000000 --- a/bot/exts/easter/egg_facts.py +++ /dev/null @@ -1,55 +0,0 @@ -import logging -import random -from json import loads -from pathlib import Path - -import discord -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Channels, Colours, Month -from bot.utils.decorators import seasonal_task - -log = logging.getLogger(__name__) - -EGG_FACTS = loads(Path("bot/resources/easter/easter_egg_facts.json").read_text("utf8")) - - -class EasterFacts(commands.Cog): - """ - A cog contains a command that will return an easter egg fact when called. - - It also contains a background task which sends an easter egg fact in the event channel everyday. - """ - - def __init__(self, bot: Bot): - self.bot = bot - self.daily_fact_task = self.bot.loop.create_task(self.send_egg_fact_daily()) - - @seasonal_task(Month.APRIL) - async def send_egg_fact_daily(self) -> None: - """A background task that sends an easter egg fact in the event channel everyday.""" - await self.bot.wait_until_guild_available() - - channel = self.bot.get_channel(Channels.community_bot_commands) - await channel.send(embed=self.make_embed()) - - @commands.command(name="eggfact", aliases=("fact",)) - async def easter_facts(self, ctx: commands.Context) -> None: - """Get easter egg facts.""" - embed = self.make_embed() - await ctx.send(embed=embed) - - @staticmethod - def make_embed() -> discord.Embed: - """Makes a nice embed for the message to be sent.""" - return discord.Embed( - colour=Colours.soft_red, - title="Easter Egg Fact", - description=random.choice(EGG_FACTS) - ) - - -def setup(bot: Bot) -> None: - """Load the Easter Egg facts Cog.""" - bot.add_cog(EasterFacts(bot)) diff --git a/bot/exts/easter/egghead_quiz.py b/bot/exts/easter/egghead_quiz.py deleted file mode 100644 index ad550567..00000000 --- a/bot/exts/easter/egghead_quiz.py +++ /dev/null @@ -1,118 +0,0 @@ -import asyncio -import logging -import random -from json import loads -from pathlib import Path -from typing import Union - -import discord -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Colours - -log = logging.getLogger(__name__) - -EGGHEAD_QUESTIONS = loads(Path("bot/resources/easter/egghead_questions.json").read_text("utf8")) - - -EMOJIS = [ - "\U0001f1e6", "\U0001f1e7", "\U0001f1e8", "\U0001f1e9", "\U0001f1ea", - "\U0001f1eb", "\U0001f1ec", "\U0001f1ed", "\U0001f1ee", "\U0001f1ef", - "\U0001f1f0", "\U0001f1f1", "\U0001f1f2", "\U0001f1f3", "\U0001f1f4", - "\U0001f1f5", "\U0001f1f6", "\U0001f1f7", "\U0001f1f8", "\U0001f1f9", - "\U0001f1fa", "\U0001f1fb", "\U0001f1fc", "\U0001f1fd", "\U0001f1fe", - "\U0001f1ff" -] # Regional Indicators A-Z (used for voting) - -TIMELIMIT = 30 - - -class EggheadQuiz(commands.Cog): - """This cog contains the command for the Easter quiz!""" - - def __init__(self): - self.quiz_messages = {} - - @commands.command(aliases=("eggheadquiz", "easterquiz")) - async def eggquiz(self, ctx: commands.Context) -> None: - """ - Gives a random quiz question, waits 30 seconds and then outputs the answer. - - Also informs of the percentages and votes of each option - """ - random_question = random.choice(EGGHEAD_QUESTIONS) - question, answers = random_question["question"], random_question["answers"] - answers = [(EMOJIS[i], a) for i, a in enumerate(answers)] - correct = EMOJIS[random_question["correct_answer"]] - - valid_emojis = [emoji for emoji, _ in answers] - - description = f"You have {TIMELIMIT} seconds to vote.\n\n" - description += "\n".join([f"{emoji} -> **{answer}**" for emoji, answer in answers]) - - q_embed = discord.Embed(title=question, description=description, colour=Colours.pink) - - msg = await ctx.send(embed=q_embed) - for emoji in valid_emojis: - await msg.add_reaction(emoji) - - self.quiz_messages[msg.id] = valid_emojis - - await asyncio.sleep(TIMELIMIT) - - del self.quiz_messages[msg.id] - - msg = await ctx.fetch_message(msg.id) # Refreshes message - - total_no = sum([len(await r.users().flatten()) for r in msg.reactions]) - len(valid_emojis) # - bot's reactions - - if total_no == 0: - return await msg.delete() # To avoid ZeroDivisionError if nobody reacts - - results = ["**VOTES:**"] - for emoji, _ in answers: - num = [len(await r.users().flatten()) for r in msg.reactions if str(r.emoji) == emoji][0] - 1 - percent = round(100 * num / total_no) - s = "" if num == 1 else "s" - string = f"{emoji} - {num} vote{s} ({percent}%)" - results.append(string) - - mentions = " ".join([ - u.mention for u in [ - await r.users().flatten() for r in msg.reactions if str(r.emoji) == correct - ][0] if not u.bot - ]) - - content = f"Well done {mentions} for getting it correct!" if mentions else "Nobody got it right..." - - a_embed = discord.Embed( - title=f"The correct answer was {correct}!", - description="\n".join(results), - colour=Colours.pink - ) - - await ctx.send(content, embed=a_embed) - - @staticmethod - async def already_reacted(message: discord.Message, user: Union[discord.Member, discord.User]) -> bool: - """Returns whether a given user has reacted more than once to a given message.""" - users = [u.id for reaction in [await r.users().flatten() for r in message.reactions] for u in reaction] - return users.count(user.id) > 1 # Old reaction plus new reaction - - @commands.Cog.listener() - async def on_reaction_add(self, reaction: discord.Reaction, user: Union[discord.Member, discord.User]) -> None: - """Listener to listen specifically for reactions of quiz messages.""" - if user.bot: - return - if reaction.message.id not in self.quiz_messages: - return - if str(reaction.emoji) not in self.quiz_messages[reaction.message.id]: - return await reaction.message.remove_reaction(reaction, user) - if await self.already_reacted(reaction.message, user): - return await reaction.message.remove_reaction(reaction, user) - - -def setup(bot: Bot) -> None: - """Load the Egghead Quiz Cog.""" - bot.add_cog(EggheadQuiz()) diff --git a/bot/exts/easter/save_the_planet.py b/bot/exts/easter/save_the_planet.py deleted file mode 100644 index 1bd515f2..00000000 --- a/bot/exts/easter/save_the_planet.py +++ /dev/null @@ -1,25 +0,0 @@ -import json -from pathlib import Path - -from discord import Embed -from discord.ext import commands - -from bot.bot import Bot -from bot.utils.randomization import RandomCycle - -EMBED_DATA = RandomCycle(json.loads(Path("bot/resources/easter/save_the_planet.json").read_text("utf8"))) - - -class SaveThePlanet(commands.Cog): - """A cog that teaches users how they can help our planet.""" - - @commands.command(aliases=("savetheearth", "saveplanet", "saveearth")) - async def savetheplanet(self, ctx: commands.Context) -> None: - """Responds with a random tip on how to be eco-friendly and help our planet.""" - return_embed = Embed.from_dict(next(EMBED_DATA)) - await ctx.send(embed=return_embed) - - -def setup(bot: Bot) -> None: - """Load the Save the Planet Cog.""" - bot.add_cog(SaveThePlanet()) diff --git a/bot/exts/easter/traditions.py b/bot/exts/easter/traditions.py deleted file mode 100644 index 93404f3e..00000000 --- a/bot/exts/easter/traditions.py +++ /dev/null @@ -1,28 +0,0 @@ -import json -import logging -import random -from pathlib import Path - -from discord.ext import commands - -from bot.bot import Bot - -log = logging.getLogger(__name__) - -traditions = json.loads(Path("bot/resources/easter/traditions.json").read_text("utf8")) - - -class Traditions(commands.Cog): - """A cog which allows users to get a random easter tradition or custom from a random country.""" - - @commands.command(aliases=("eastercustoms",)) - async def easter_tradition(self, ctx: commands.Context) -> None: - """Responds with a random tradition or custom.""" - random_country = random.choice(list(traditions)) - - await ctx.send(f"{random_country}:\n{traditions[random_country]}") - - -def setup(bot: Bot) -> None: - """Load the Traditions Cog.""" - bot.add_cog(Traditions()) diff --git a/bot/exts/halloween/__init__.py b/bot/exts/halloween/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/bot/exts/holidays/__init__.py b/bot/exts/holidays/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bot/exts/holidays/earth_day/__init__.py b/bot/exts/holidays/earth_day/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bot/exts/holidays/earth_day/save_the_planet.py b/bot/exts/holidays/earth_day/save_the_planet.py new file mode 100644 index 00000000..13c84886 --- /dev/null +++ b/bot/exts/holidays/earth_day/save_the_planet.py @@ -0,0 +1,25 @@ +import json +from pathlib import Path + +from discord import Embed +from discord.ext import commands + +from bot.bot import Bot +from bot.utils.randomization import RandomCycle + +EMBED_DATA = RandomCycle(json.loads(Path("bot/resources/holidays/earth_day/save_the_planet.json").read_text("utf8"))) + + +class SaveThePlanet(commands.Cog): + """A cog that teaches users how they can help our planet.""" + + @commands.command(aliases=("savetheearth", "saveplanet", "saveearth")) + async def savetheplanet(self, ctx: commands.Context) -> None: + """Responds with a random tip on how to be eco-friendly and help our planet.""" + return_embed = Embed.from_dict(next(EMBED_DATA)) + await ctx.send(embed=return_embed) + + +def setup(bot: Bot) -> None: + """Load the Save the Planet Cog.""" + bot.add_cog(SaveThePlanet()) diff --git a/bot/exts/holidays/easter/__init__.py b/bot/exts/holidays/easter/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bot/exts/holidays/easter/april_fools_vids.py b/bot/exts/holidays/easter/april_fools_vids.py new file mode 100644 index 00000000..ae22f751 --- /dev/null +++ b/bot/exts/holidays/easter/april_fools_vids.py @@ -0,0 +1,30 @@ +import logging +import random +from json import loads +from pathlib import Path + +from discord.ext import commands + +from bot.bot import Bot + +log = logging.getLogger(__name__) + +ALL_VIDS = loads(Path("bot/resources/holidays/easter/april_fools_vids.json").read_text("utf-8")) + + +class AprilFoolVideos(commands.Cog): + """A cog for April Fools' that gets a random April Fools' video from Youtube.""" + + @commands.command(name="fool") + async def april_fools(self, ctx: commands.Context) -> None: + """Get a random April Fools' video from Youtube.""" + video = random.choice(ALL_VIDS) + + channel, url = video["channel"], video["url"] + + await ctx.send(f"Check out this April Fools' video by {channel}.\n\n{url}") + + +def setup(bot: Bot) -> None: + """Load the April Fools' Cog.""" + bot.add_cog(AprilFoolVideos()) diff --git a/bot/exts/holidays/easter/bunny_name_generator.py b/bot/exts/holidays/easter/bunny_name_generator.py new file mode 100644 index 00000000..f767f7c5 --- /dev/null +++ b/bot/exts/holidays/easter/bunny_name_generator.py @@ -0,0 +1,94 @@ +import json +import logging +import random +import re +from pathlib import Path +from typing import Optional + +from discord.ext import commands + +from bot.bot import Bot + +log = logging.getLogger(__name__) + +BUNNY_NAMES = json.loads(Path("bot/resources/holidays/easter/bunny_names.json").read_text("utf8")) + + +class BunnyNameGenerator(commands.Cog): + """Generate a random bunny name, or bunnify your Discord username!""" + + @staticmethod + def find_separators(displayname: str) -> Optional[list[str]]: + """Check if Discord name contains spaces so we can bunnify an individual word in the name.""" + new_name = re.split(r"[_.\s]", displayname) + if displayname not in new_name: + return new_name + return None + + @staticmethod + def find_vowels(displayname: str) -> Optional[str]: + """ + Finds vowels in the user's display name. + + If the Discord name contains a vowel and the letter y, it will match one or more of these patterns. + + Only the most recently matched pattern will apply the changes. + """ + expressions = [ + ("a.+y", "patchy"), + ("e.+y", "ears"), + ("i.+y", "ditsy"), + ("o.+y", "oofy"), + ("u.+y", "uffy"), + ] + + for exp, vowel_sub in expressions: + new_name = re.sub(exp, vowel_sub, displayname) + if new_name != displayname: + return new_name + + @staticmethod + def append_name(displayname: str) -> str: + """Adds a suffix to the end of the Discord name.""" + extensions = ["foot", "ear", "nose", "tail"] + suffix = random.choice(extensions) + appended_name = displayname + suffix + + return appended_name + + @commands.command() + async def bunnyname(self, ctx: commands.Context) -> None: + """Picks a random bunny name from a JSON file.""" + await ctx.send(random.choice(BUNNY_NAMES["names"])) + + @commands.command() + async def bunnifyme(self, ctx: commands.Context) -> None: + """Gets your Discord username and bunnifies it.""" + username = ctx.author.display_name + + # If name contains spaces or other separators, get the individual words to randomly bunnify + spaces_in_name = self.find_separators(username) + + # If name contains vowels, see if it matches any of the patterns in this function + # If there are matches, the bunnified name is returned. + vowels_in_name = self.find_vowels(username) + + # Default if the checks above return None + unmatched_name = self.append_name(username) + + if spaces_in_name is not None: + replacements = ["Cotton", "Fluff", "Floof" "Bounce", "Snuffle", "Nibble", "Cuddle", "Velvetpaw", "Carrot"] + word_to_replace = random.choice(spaces_in_name) + substitute = random.choice(replacements) + bunnified_name = username.replace(word_to_replace, substitute) + elif vowels_in_name is not None: + bunnified_name = vowels_in_name + elif unmatched_name: + bunnified_name = unmatched_name + + await ctx.send(bunnified_name) + + +def setup(bot: Bot) -> None: + """Load the Bunny Name Generator Cog.""" + bot.add_cog(BunnyNameGenerator()) diff --git a/bot/exts/holidays/easter/earth_photos.py b/bot/exts/holidays/easter/earth_photos.py new file mode 100644 index 00000000..f65790af --- /dev/null +++ b/bot/exts/holidays/easter/earth_photos.py @@ -0,0 +1,66 @@ +import logging + +import discord +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Colours +from bot.constants import Tokens + +log = logging.getLogger(__name__) + +API_URL = "https://api.unsplash.com/photos/random" + + +class EarthPhotos(commands.Cog): + """This cog contains the command for earth photos.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @commands.command(aliases=("earth",)) + async def earth_photos(self, ctx: commands.Context) -> None: + """Returns a random photo of earth, sourced from Unsplash.""" + async with ctx.typing(): + async with self.bot.http_session.get( + API_URL, + params={"query": "planet_earth", "client_id": Tokens.unsplash_access_key} + ) as r: + jsondata = await r.json() + linksdata = jsondata.get("urls") + embedlink = linksdata.get("regular") + downloadlinksdata = jsondata.get("links") + userdata = jsondata.get("user") + username = userdata.get("name") + userlinks = userdata.get("links") + profile = userlinks.get("html") + # Referral flags + rf = "?utm_source=Sir%20Lancebot&utm_medium=referral" + async with self.bot.http_session.get( + downloadlinksdata.get("download_location"), + params={"client_id": Tokens.unsplash_access_key} + ) as _: + pass + + embed = discord.Embed( + title="Earth Photo", + description="A photo of Earth 🌎 from Unsplash.", + color=Colours.grass_green + ) + embed.set_image(url=embedlink) + embed.add_field( + name="Author", + value=( + f"Photo by [{username}]({profile}{rf}) " + f"on [Unsplash](https://unsplash.com{rf})." + ) + ) + await ctx.send(embed=embed) + + +def setup(bot: Bot) -> None: + """Load the Earth Photos cog.""" + if not Tokens.unsplash_access_key: + log.warning("No Unsplash access key found. Cog not loading.") + return + bot.add_cog(EarthPhotos(bot)) diff --git a/bot/exts/holidays/easter/easter_riddle.py b/bot/exts/holidays/easter/easter_riddle.py new file mode 100644 index 00000000..c9b7fc53 --- /dev/null +++ b/bot/exts/holidays/easter/easter_riddle.py @@ -0,0 +1,112 @@ +import asyncio +import logging +import random +from json import loads +from pathlib import Path + +import discord +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Colours, NEGATIVE_REPLIES + +log = logging.getLogger(__name__) + +RIDDLE_QUESTIONS = loads(Path("bot/resources/holidays/easter/easter_riddle.json").read_text("utf8")) + +TIMELIMIT = 10 + + +class EasterRiddle(commands.Cog): + """This cog contains the command for the Easter quiz!""" + + def __init__(self, bot: Bot): + self.bot = bot + self.winners = set() + self.correct = "" + self.current_channel = None + + @commands.command(aliases=("riddlemethis", "riddleme")) + async def riddle(self, ctx: commands.Context) -> None: + """ + Gives a random riddle, then provides 2 hints at certain intervals before revealing the answer. + + The duration of the hint interval can be configured by changing the TIMELIMIT constant in this file. + """ + if self.current_channel: + await ctx.send(f"A riddle is already being solved in {self.current_channel.mention}!") + return + + # Don't let users start in a DM + if not ctx.guild: + await ctx.send( + embed=discord.Embed( + title=random.choice(NEGATIVE_REPLIES), + description="You can't start riddles in DMs", + colour=discord.Colour.red() + ) + ) + return + + self.current_channel = ctx.channel + + random_question = random.choice(RIDDLE_QUESTIONS) + question = random_question["question"] + hints = random_question["riddles"] + self.correct = random_question["correct_answer"] + + description = f"You have {TIMELIMIT} seconds before the first hint." + + riddle_embed = discord.Embed(title=question, description=description, colour=Colours.pink) + + await ctx.send(embed=riddle_embed) + await asyncio.sleep(TIMELIMIT) + + hint_embed = discord.Embed( + title=f"Here's a hint: {hints[0]}!", + colour=Colours.pink + ) + + await ctx.send(embed=hint_embed) + await asyncio.sleep(TIMELIMIT) + + hint_embed = discord.Embed( + title=f"Here's a hint: {hints[1]}!", + colour=Colours.pink + ) + + await ctx.send(embed=hint_embed) + await asyncio.sleep(TIMELIMIT) + + if self.winners: + win_list = " ".join(self.winners) + content = f"Well done {win_list} for getting it right!" + else: + content = "Nobody got it right..." + + answer_embed = discord.Embed( + title=f"The answer is: {self.correct}!", + colour=Colours.pink + ) + + await ctx.send(content, embed=answer_embed) + + self.winners.clear() + self.current_channel = None + + @commands.Cog.listener() + async def on_message(self, message: discord.Message) -> None: + """If a non-bot user enters a correct answer, their username gets added to self.winners.""" + if self.current_channel != message.channel: + return + + if self.bot.user == message.author: + return + + if message.content.lower() == self.correct.lower(): + self.winners.add(message.author.mention) + + +def setup(bot: Bot) -> None: + """Easter Riddle Cog load.""" + bot.add_cog(EasterRiddle(bot)) diff --git a/bot/exts/holidays/easter/egg_decorating.py b/bot/exts/holidays/easter/egg_decorating.py new file mode 100644 index 00000000..1db9b347 --- /dev/null +++ b/bot/exts/holidays/easter/egg_decorating.py @@ -0,0 +1,119 @@ +import json +import logging +import random +from contextlib import suppress +from io import BytesIO +from pathlib import Path +from typing import Optional, Union + +import discord +from PIL import Image +from discord.ext import commands + +from bot.bot import Bot +from bot.utils import helpers + +log = logging.getLogger(__name__) + +HTML_COLOURS = json.loads(Path("bot/resources/fun/html_colours.json").read_text("utf8")) + +XKCD_COLOURS = json.loads(Path("bot/resources/fun/xkcd_colours.json").read_text("utf8")) + +COLOURS = [ + (255, 0, 0, 255), (255, 128, 0, 255), (255, 255, 0, 255), (0, 255, 0, 255), + (0, 255, 255, 255), (0, 0, 255, 255), (255, 0, 255, 255), (128, 0, 128, 255) +] # Colours to be replaced - Red, Orange, Yellow, Green, Light Blue, Dark Blue, Pink, Purple + +IRREPLACEABLE = [ + (0, 0, 0, 0), (0, 0, 0, 255) +] # Colours that are meant to stay the same - Transparent and Black + + +class EggDecorating(commands.Cog): + """Decorate some easter eggs!""" + + @staticmethod + def replace_invalid(colour: str) -> Optional[int]: + """Attempts to match with HTML or XKCD colour names, returning the int value.""" + with suppress(KeyError): + return int(HTML_COLOURS[colour], 16) + with suppress(KeyError): + return int(XKCD_COLOURS[colour], 16) + return None + + @commands.command(aliases=("decorateegg",)) + async def eggdecorate( + self, ctx: commands.Context, *colours: Union[discord.Colour, str] + ) -> Optional[Image.Image]: + """ + Picks a random egg design and decorates it using the given colours. + + Colours are split by spaces, unless you wrap the colour name in double quotes. + Discord colour names, HTML colour names, XKCD colour names and hex values are accepted. + """ + if len(colours) < 2: + await ctx.send("You must include at least 2 colours!") + return + + invalid = [] + colours = list(colours) + for idx, colour in enumerate(colours): + if isinstance(colour, discord.Colour): + continue + value = self.replace_invalid(colour) + if value: + colours[idx] = discord.Colour(value) + else: + invalid.append(helpers.suppress_links(colour)) + + if len(invalid) > 1: + await ctx.send(f"Sorry, I don't know these colours: {' '.join(invalid)}") + return + elif len(invalid) == 1: + await ctx.send(f"Sorry, I don't know the colour {invalid[0]}!") + return + + async with ctx.typing(): + # Expand list to 8 colours + colours_n = len(colours) + if colours_n < 8: + q, r = divmod(8, colours_n) + colours = colours * q + colours[:r] + num = random.randint(1, 6) + im = Image.open(Path(f"bot/resources/holidays/easter/easter_eggs/design{num}.png")) + data = list(im.getdata()) + + replaceable = {x for x in data if x not in IRREPLACEABLE} + replaceable = sorted(replaceable, key=COLOURS.index) + + replacing_colours = {colour: colours[i] for i, colour in enumerate(replaceable)} + new_data = [] + for x in data: + if x in replacing_colours: + new_data.append((*replacing_colours[x].to_rgb(), 255)) + # Also ensures that the alpha channel has a value + else: + new_data.append(x) + new_im = Image.new(im.mode, im.size) + new_im.putdata(new_data) + + bufferedio = BytesIO() + new_im.save(bufferedio, format="PNG") + + bufferedio.seek(0) + + file = discord.File(bufferedio, filename="egg.png") # Creates file to be used in embed + embed = discord.Embed( + title="Your Colourful Easter Egg", + description="Here is your pretty little egg. Hope you like it!" + ) + embed.set_image(url="attachment://egg.png") + embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.display_avatar.url) + + await ctx.send(file=file, embed=embed) + return new_im + + +def setup(bot: Bot) -> None: + """Load the Egg decorating Cog.""" + bot.add_cog(EggDecorating()) diff --git a/bot/exts/holidays/easter/egg_facts.py b/bot/exts/holidays/easter/egg_facts.py new file mode 100644 index 00000000..5f216e0d --- /dev/null +++ b/bot/exts/holidays/easter/egg_facts.py @@ -0,0 +1,55 @@ +import logging +import random +from json import loads +from pathlib import Path + +import discord +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Channels, Colours, Month +from bot.utils.decorators import seasonal_task + +log = logging.getLogger(__name__) + +EGG_FACTS = loads(Path("bot/resources/holidays/easter/easter_egg_facts.json").read_text("utf8")) + + +class EasterFacts(commands.Cog): + """ + A cog contains a command that will return an easter egg fact when called. + + It also contains a background task which sends an easter egg fact in the event channel everyday. + """ + + def __init__(self, bot: Bot): + self.bot = bot + self.daily_fact_task = self.bot.loop.create_task(self.send_egg_fact_daily()) + + @seasonal_task(Month.APRIL) + async def send_egg_fact_daily(self) -> None: + """A background task that sends an easter egg fact in the event channel everyday.""" + await self.bot.wait_until_guild_available() + + channel = self.bot.get_channel(Channels.community_bot_commands) + await channel.send(embed=self.make_embed()) + + @commands.command(name="eggfact", aliases=("fact",)) + async def easter_facts(self, ctx: commands.Context) -> None: + """Get easter egg facts.""" + embed = self.make_embed() + await ctx.send(embed=embed) + + @staticmethod + def make_embed() -> discord.Embed: + """Makes a nice embed for the message to be sent.""" + return discord.Embed( + colour=Colours.soft_red, + title="Easter Egg Fact", + description=random.choice(EGG_FACTS) + ) + + +def setup(bot: Bot) -> None: + """Load the Easter Egg facts Cog.""" + bot.add_cog(EasterFacts(bot)) diff --git a/bot/exts/holidays/easter/egghead_quiz.py b/bot/exts/holidays/easter/egghead_quiz.py new file mode 100644 index 00000000..06229537 --- /dev/null +++ b/bot/exts/holidays/easter/egghead_quiz.py @@ -0,0 +1,118 @@ +import asyncio +import logging +import random +from json import loads +from pathlib import Path +from typing import Union + +import discord +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Colours + +log = logging.getLogger(__name__) + +EGGHEAD_QUESTIONS = loads(Path("bot/resources/holidays/easter/egghead_questions.json").read_text("utf8")) + + +EMOJIS = [ + "\U0001f1e6", "\U0001f1e7", "\U0001f1e8", "\U0001f1e9", "\U0001f1ea", + "\U0001f1eb", "\U0001f1ec", "\U0001f1ed", "\U0001f1ee", "\U0001f1ef", + "\U0001f1f0", "\U0001f1f1", "\U0001f1f2", "\U0001f1f3", "\U0001f1f4", + "\U0001f1f5", "\U0001f1f6", "\U0001f1f7", "\U0001f1f8", "\U0001f1f9", + "\U0001f1fa", "\U0001f1fb", "\U0001f1fc", "\U0001f1fd", "\U0001f1fe", + "\U0001f1ff" +] # Regional Indicators A-Z (used for voting) + +TIMELIMIT = 30 + + +class EggheadQuiz(commands.Cog): + """This cog contains the command for the Easter quiz!""" + + def __init__(self): + self.quiz_messages = {} + + @commands.command(aliases=("eggheadquiz", "easterquiz")) + async def eggquiz(self, ctx: commands.Context) -> None: + """ + Gives a random quiz question, waits 30 seconds and then outputs the answer. + + Also informs of the percentages and votes of each option + """ + random_question = random.choice(EGGHEAD_QUESTIONS) + question, answers = random_question["question"], random_question["answers"] + answers = [(EMOJIS[i], a) for i, a in enumerate(answers)] + correct = EMOJIS[random_question["correct_answer"]] + + valid_emojis = [emoji for emoji, _ in answers] + + description = f"You have {TIMELIMIT} seconds to vote.\n\n" + description += "\n".join([f"{emoji} -> **{answer}**" for emoji, answer in answers]) + + q_embed = discord.Embed(title=question, description=description, colour=Colours.pink) + + msg = await ctx.send(embed=q_embed) + for emoji in valid_emojis: + await msg.add_reaction(emoji) + + self.quiz_messages[msg.id] = valid_emojis + + await asyncio.sleep(TIMELIMIT) + + del self.quiz_messages[msg.id] + + msg = await ctx.fetch_message(msg.id) # Refreshes message + + total_no = sum([len(await r.users().flatten()) for r in msg.reactions]) - len(valid_emojis) # - bot's reactions + + if total_no == 0: + return await msg.delete() # To avoid ZeroDivisionError if nobody reacts + + results = ["**VOTES:**"] + for emoji, _ in answers: + num = [len(await r.users().flatten()) for r in msg.reactions if str(r.emoji) == emoji][0] - 1 + percent = round(100 * num / total_no) + s = "" if num == 1 else "s" + string = f"{emoji} - {num} vote{s} ({percent}%)" + results.append(string) + + mentions = " ".join([ + u.mention for u in [ + await r.users().flatten() for r in msg.reactions if str(r.emoji) == correct + ][0] if not u.bot + ]) + + content = f"Well done {mentions} for getting it correct!" if mentions else "Nobody got it right..." + + a_embed = discord.Embed( + title=f"The correct answer was {correct}!", + description="\n".join(results), + colour=Colours.pink + ) + + await ctx.send(content, embed=a_embed) + + @staticmethod + async def already_reacted(message: discord.Message, user: Union[discord.Member, discord.User]) -> bool: + """Returns whether a given user has reacted more than once to a given message.""" + users = [u.id for reaction in [await r.users().flatten() for r in message.reactions] for u in reaction] + return users.count(user.id) > 1 # Old reaction plus new reaction + + @commands.Cog.listener() + async def on_reaction_add(self, reaction: discord.Reaction, user: Union[discord.Member, discord.User]) -> None: + """Listener to listen specifically for reactions of quiz messages.""" + if user.bot: + return + if reaction.message.id not in self.quiz_messages: + return + if str(reaction.emoji) not in self.quiz_messages[reaction.message.id]: + return await reaction.message.remove_reaction(reaction, user) + if await self.already_reacted(reaction.message, user): + return await reaction.message.remove_reaction(reaction, user) + + +def setup(bot: Bot) -> None: + """Load the Egghead Quiz Cog.""" + bot.add_cog(EggheadQuiz()) diff --git a/bot/exts/holidays/easter/traditions.py b/bot/exts/holidays/easter/traditions.py new file mode 100644 index 00000000..f54ab5c4 --- /dev/null +++ b/bot/exts/holidays/easter/traditions.py @@ -0,0 +1,28 @@ +import json +import logging +import random +from pathlib import Path + +from discord.ext import commands + +from bot.bot import Bot + +log = logging.getLogger(__name__) + +traditions = json.loads(Path("bot/resources/holidays/easter/traditions.json").read_text("utf8")) + + +class Traditions(commands.Cog): + """A cog which allows users to get a random easter tradition or custom from a random country.""" + + @commands.command(aliases=("eastercustoms",)) + async def easter_tradition(self, ctx: commands.Context) -> None: + """Responds with a random tradition or custom.""" + random_country = random.choice(list(traditions)) + + await ctx.send(f"{random_country}:\n{traditions[random_country]}") + + +def setup(bot: Bot) -> None: + """Load the Traditions Cog.""" + bot.add_cog(Traditions()) diff --git a/bot/exts/pride/__init__.py b/bot/exts/pride/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/bot/resources/easter/april_fools_vids.json b/bot/resources/easter/april_fools_vids.json deleted file mode 100644 index e1e8c70a..00000000 --- a/bot/resources/easter/april_fools_vids.json +++ /dev/null @@ -1,130 +0,0 @@ -[ - { - "url": "https://youtu.be/OYcv406J_J4", - "channel": "google" - }, - { - "url": "https://youtu.be/0_5X6N6DHyk", - "channel": "google" - }, - { - "url": "https://youtu.be/UmJ2NBHXTqo", - "channel": "google" - }, - { - "url": "https://youtu.be/3MA6_21nka8", - "channel": "google" - }, - { - "url": "https://youtu.be/QAwL0O5nXe0", - "channel": "google" - }, - { - "url": "https://youtu.be/DPEJB-FCItk", - "channel": "google" - }, - { - "url": "https://youtu.be/LSZPNwZex9s", - "channel": "google" - }, - { - "url": "https://youtu.be/dFrgNiweQDk", - "channel": "google" - }, - { - "url": "https://youtu.be/F0F6SnbqUcE", - "channel": "google" - }, - { - "url": "https://youtu.be/VkOuShXpoKc", - "channel": "google" - }, - { - "url": "https://youtu.be/HQtGFBbwKEk", - "channel": "google" - }, - { - "url": "https://youtu.be/Cp10_PygJ4o", - "channel": "google" - }, - { - "url": "https://youtu.be/XTTtkisylQw", - "channel": "google" - }, - { - "url": "https://youtu.be/hydLZJXG3Tk", - "channel": "google" - }, - { - "url": "https://youtu.be/U2JBFlW--UU", - "channel": "google" - }, - { - "url": "https://youtu.be/G3NXNnoGr3Y", - "channel": "google" - }, - { - "url": "https://youtu.be/4YMD6xELI_k", - "channel": "google" - }, - { - "url": "https://youtu.be/qcgWRpQP6ds", - "channel": "google" - }, - { - "url": "https://youtu.be/Zr4JwPb99qU", - "channel": "google" - }, - { - "url": "https://youtu.be/VFbYadm_mrw", - "channel": "google" - }, - { - "url": "https://youtu.be/_qFFHC0eIUc", - "channel": "google" - }, - { - "url": "https://youtu.be/H542nLTTbu0", - "channel": "google" - }, - { - "url": "https://youtu.be/Je7Xq9tdCJc", - "channel": "google" - }, - { - "url": "https://youtu.be/re0VRK6ouwI", - "channel": "google" - }, - { - "url": "https://youtu.be/1KhZKNZO8mQ", - "channel": "google" - }, - { - "url": "https://youtu.be/UiLSiqyDf4Y", - "channel": "google" - }, - { - "url": "https://youtu.be/rznYifPHxDg", - "channel": "google" - }, - { - "url": "https://youtu.be/blB_X38YSxQ", - "channel": "google" - }, - { - "url": "https://youtu.be/Bu927_ul_X0", - "channel": "google" - }, - { - "url": "https://youtu.be/smM-Wdk2RLQ", - "channel": "nvidia" - }, - { - "url": "https://youtu.be/IlCx5gjAmqI", - "channel": "razer" - }, - { - "url": "https://youtu.be/j8UJE7DoyJ8", - "channel": "razer" - } -] diff --git a/bot/resources/easter/bunny_names.json b/bot/resources/easter/bunny_names.json deleted file mode 100644 index 8c97169c..00000000 --- a/bot/resources/easter/bunny_names.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "names": [ - "Flopsy", - "Hopsalot", - "Thumper", - "Nibbles", - "Daisy", - "Fuzzy", - "Cottontail", - "Carrot Top", - "Marshmallow", - "Lucky", - "Clover", - "Daffodil", - "Buttercup", - "Goldie", - "Dizzy", - "Trixie", - "Snuffles", - "Hopscotch", - "Skipper", - "Thunderfoot", - "Bigwig", - "Dandelion", - "Pipkin", - "Buckthorn", - "Skipper" - ] -} diff --git a/bot/resources/easter/chocolate_bunny.png b/bot/resources/easter/chocolate_bunny.png deleted file mode 100644 index 6b25aa5a..00000000 Binary files a/bot/resources/easter/chocolate_bunny.png and /dev/null differ diff --git a/bot/resources/easter/easter_egg_facts.json b/bot/resources/easter/easter_egg_facts.json deleted file mode 100644 index b0650b84..00000000 --- a/bot/resources/easter/easter_egg_facts.json +++ /dev/null @@ -1,17 +0,0 @@ -[ - "The first story of a rabbit (later named the \"Easter Bunny\") hiding eggs in a garden was published in 1680.", - "Rabbits are known to be prolific pro creators and are an ancient symbol of fertility and new life. The German immigrants brought the tale of Easter Bunny in the 1700s with the tradition of an egg-laying hare called \"Osterhase\". The kids then would make nests in which the creature would lay coloured eggs. The tradition has been revolutionized in the form of candies and gifts instead of eggs.", - "In earlier days, a festival of egg throwing was held in church, when the priest would throw a hard-boiled egg to one of the choirboys. It was then tossed from one choirboy to the next and whoever held the egg when the clock struck 12 on Easter, was the winner and could keep it.", - "In medieval times, Easter eggs were boiled with onions to give them a golden sheen. Edward I went beyond this tradition in 1290 and ordered 450 eggs to be covered in gold leaf and given as Easter gifts.", - "Decorating Easter eggs is an ancient tradition that dates back to 13th century. One of the explanations for this custom is that eggs were considered as a forbidden food during the Lenten season (40 days before Easter). Therefore, people would paint and decorate them to mark an end of the period of penance and fasting and later eat them on Easter. The tradition of decorating eggs is called Pysanka which is creating a traditional Ukrainian folk design using wax-resist method.", - "Members of the Greek Orthodox faith often paint their Easter eggs red, which symbolizes Jesus' blood and his victory over death. The color red, symbolizes renewal of life, such as, Jesus' resurrection.", - "Eggs rolling take place in many parts of the world which symbolizes stone which was rolled away from the tomb where Jesus' body was laid after his death.", - "Easter eggs have been considered as a symbol of fertility, rebirth and new life. The custom of giving eggs has been derived from Egyptians, Persians, Gauls, Greeks, and Romans.", - "The first chocolate Easter egg was made by Fry's in 1873. Before this, people would give hollow cardboard eggs, filled with gifts.", - "The tallest chocolate Easter egg was made in Italy in 2011. Standing 10.39 metres tall and weighing 7,200 kg, it was taller than a giraffe and heavier than an elephant.", - "The largest ever Easter egg hunt was in Florida, where 9,753 children searched for 501,000 eggs.", - "In 2007, an Easter egg covered in diamonds sold for almost £9 million. Every hour, a cockerel made of jewels pops up from the top of the Faberge egg, flaps its wings four times, nods its head three times and makes a crowing noise. The gold-and-pink enamel egg was made by the Russian royal family as an engagement gift for French aristocrat Baron Edouard de Rothschild.", - "The White House held their first official egg roll in 1878 when Rutherford B. Hayes was the President. It is a race in which children push decorated, hard-boiled eggs across the White House lawn as an annual event held the Monday after Easter. In 2009, the Obamas hosted their first Easter egg roll with the theme, \"Let's go play\" which was meant to encourage young people to lead healthy and active lives.", - "80 million chocolate Easter eggs are sold each year. This accounts for 10% of Britain's annual spending on chocolate!", - "John Cadbury soon followed suit and made his first Cadbury Easter egg in 1875. By 1892 the company was producing 19 different lines, all made from dark chocolate." -] diff --git a/bot/resources/easter/easter_eggs/design1.png b/bot/resources/easter/easter_eggs/design1.png deleted file mode 100644 index d887c590..00000000 Binary files a/bot/resources/easter/easter_eggs/design1.png and /dev/null differ diff --git a/bot/resources/easter/easter_eggs/design2.png b/bot/resources/easter/easter_eggs/design2.png deleted file mode 100644 index c4fff644..00000000 Binary files a/bot/resources/easter/easter_eggs/design2.png and /dev/null differ diff --git a/bot/resources/easter/easter_eggs/design3.png b/bot/resources/easter/easter_eggs/design3.png deleted file mode 100644 index 803bc1e3..00000000 Binary files a/bot/resources/easter/easter_eggs/design3.png and /dev/null differ diff --git a/bot/resources/easter/easter_eggs/design4.png b/bot/resources/easter/easter_eggs/design4.png deleted file mode 100644 index 38e6a83f..00000000 Binary files a/bot/resources/easter/easter_eggs/design4.png and /dev/null differ diff --git a/bot/resources/easter/easter_eggs/design5.png b/bot/resources/easter/easter_eggs/design5.png deleted file mode 100644 index 56662c26..00000000 Binary files a/bot/resources/easter/easter_eggs/design5.png and /dev/null differ diff --git a/bot/resources/easter/easter_eggs/design6.png b/bot/resources/easter/easter_eggs/design6.png deleted file mode 100644 index 5372439a..00000000 Binary files a/bot/resources/easter/easter_eggs/design6.png and /dev/null differ diff --git a/bot/resources/easter/easter_riddle.json b/bot/resources/easter/easter_riddle.json deleted file mode 100644 index f7eb63d8..00000000 --- a/bot/resources/easter/easter_riddle.json +++ /dev/null @@ -1,74 +0,0 @@ -[ - { - "question": "What kind of music do bunnies like?", - "riddles": [ - "Two words", - "Jump to the beat" - ], - "correct_answer": "Hip hop" - }, - { - "question": "What kind of jewelry do rabbits wear?", - "riddles": [ - "They can eat it too", - "14 ___ gold" - ], - "correct_answer": "14 carrot gold" - }, - { - "question": "What does the easter bunny get for making a basket?", - "riddles": [ - "KOBE!", - "1+1 = ?" - ], - "correct_answer": "2 points" - }, - { - "question": "Where does the easter bunny eat breakfast?", - "riddles": [ - "No waffles here", - "An international home" - ], - "correct_answer": "IHOP" - }, - { - "question": "What do you call a rabbit with fleas?", - "riddles": [ - "A bit of a looney tune", - "What's up Doc?" - ], - "correct_answer": "Bugs Bunny" - }, - { - "question": "Why was the little girl sad after the race?", - "riddles": [ - "2nd place?", - "Who beat her?" - ], - "correct_answer": "Because an egg beater" - }, - { - "question": "What happened to the Easter Bunny when he misbehaved at school?", - "riddles": [ - "Won't be back anymore", - "Worse than suspension" - ], - "correct_answer": "He was eggspelled" - }, - { - "question": "What kind of bunny can't hop?", - "riddles": [ - "Might melt in the sun", - "Fragile and yummy" - ], - "correct_answer": "A chocolate one" - }, - { - "question": "Why did the Easter Bunny have to fire the duck?", - "riddles": [ - "Quack", - "MY EGGS!!" - ], - "correct_answer": "He kept quacking the eggs" - } -] diff --git a/bot/resources/easter/egghead_questions.json b/bot/resources/easter/egghead_questions.json deleted file mode 100644 index 5535f8e0..00000000 --- a/bot/resources/easter/egghead_questions.json +++ /dev/null @@ -1,181 +0,0 @@ -[ - { - "question": "Where did the idea of the Easter Bunny originate?", - "answers": [ - "Russia", - "The United States", - "The UK", - "Germany" - ], - "correct_answer": 3 - }, - { - "question": "The Easter Bunny was originally going to be a...", - "answers": [ - "hare", - "possum", - "cat", - "dove" - ], - "correct_answer": 0 - }, - { - "question": "Which of the following is NOT a movie about Easter?", - "answers": [ - "Winnie the Pooh - Springtime with Roo", - "It's a Wonderful Life", - "The Passion of the Christ", - "Here Comes Peter Cottontail" - ], - "correct_answer": 1 - }, - { - "question": "In Australia, what animal is used instead of the Easter Bunny?", - "answers": [ - "kangaroo", - "wombat", - "koala", - "bilby" - ], - "correct_answer": 3 - }, - { - "question": "When was the first Earth Day?", - "answers": [ - "1982", - "2003", - "1999", - "1970" - ], - "correct_answer": 3 - }, - { - "question": "Who is considered to be the founder of Earth Day?", - "answers": [ - "President Jimmy Carter", - "President John F. Kennedy", - "Vice President Al Gore", - "Senator Gaylord Nelson" - ], - "correct_answer": 3 - }, - { - "question": "Approximately how many countries participated in Earth Day 2000?", - "answers": [ - "60", - "140", - "180", - "240" - ], - "correct_answer": 2 - }, - { - "question": "As Earth Day is this month, how old is the Earth?", - "answers": [ - "4.5 billion years old", - "5 million years old", - "10 billion years old", - "6.7 billion years old" - ], - "correct_answer": 0 - }, - { - "question": "As a celebration of Earth Day, what is the percentage of Oxygen in the Earth's atmosphere?", - "answers": [ - "18%", - "21%", - "25%", - "31%" - ], - "correct_answer": 1 - }, - { - "question": "In what year did Google begin its tradition of April Fools Jokes?", - "answers": [ - "1997", - "2000", - "2003", - "2007" - ], - "correct_answer": 1 - }, - { - "question": "Which type of chocolate is the most healthy?", - "answers": [ - "Dark", - "White", - "Milk" - ], - "correct_answer": 0 - }, - { - "question": "How many bars of milk chocolate would you have to eat to get the same amount of caffeine as in one cup of coffee?", - "answers": [ - "3", - "9", - "14", - "20" - ], - "correct_answer": 2 - }, - { - "question": "Aztecs used to use one of the ingedients of chocolate, cocoa beans, as...", - "answers": [ - "currency", - "medicine", - "dye", - "fertilizer" - ], - "correct_answer": 0 - }, - { - "question": "Which European country was the first to enjoy chocolate?", - "answers": [ - "France", - "Spain", - "England", - "Switzerland" - ], - "correct_answer": 1 - }, - { - "question": "The first European Chocolate Shop opened in what city in 1657?", - "answers": [ - "Paris, France", - "Madrid, Spain", - "Zürich, Switzerland", - "London, England" - ], - "correct_answer": 3 - }, - { - "question": "On average, how many eggs does a hen lay in a year?", - "answers": [ - "Between 200-230", - "Between 250-270", - "Between 300-330", - "Between 370-400" - ], - "correct_answer": 1 - }, - { - "question": "What determines the colour of an egg yolk?", - "answers": [ - "The size of the hen", - "The age of a hen", - "The diet of a hen", - "The colour of a hen's feathers" - ], - "correct_answer": 2 - }, - { - "question": "What country produces the most eggs in a year?", - "answers": [ - "China", - "India", - "The United States", - "Japan" - ], - "correct_answer": 0 - } -] diff --git a/bot/resources/easter/save_the_planet.json b/bot/resources/easter/save_the_planet.json deleted file mode 100644 index f22261b7..00000000 --- a/bot/resources/easter/save_the_planet.json +++ /dev/null @@ -1,77 +0,0 @@ -[ - { - "title": "Choose renewable energy", - "image": {"url": "https://cdn.dnaindia.com/sites/default/files/styles/full/public/2019/07/23/851602-renewable-energy-istock-072419.jpg"}, - "footer": {"text": "Help out by sharing this information!"}, - "fields": [ - { - "name": "The problem", - "value": "Getting energy from oil or fossil fuels isn't a good idea, because there is only so much of it.", - "inline": false - }, - - { - "name": "What you can do", - "value": "Use renewable energy, such as wind, solar, and hydro, because it is healthier and is not a finite resource!", - "inline": false - } - ] - }, - - { - "title": "Save the trees!", - "image": {"url": "https://www.thecollegesolution.com/wp-content/uploads/2014/07/crumpled-paper-1.jpg"}, - "footer": {"text": "Help out by sharing this information!"}, - "fields": [ - { - "name": "The problem", - "value": "We often waste trees on making paper, and just getting rid of them for no good reason.", - "inline": false - }, - - { - "name": "What you can do", - "value": "Make sure you only use paper when absolutely necessary. When you do, make sure to use recycled paper because making new paper causes pollution. Find ways to plant trees (Hacktober Fest!) to combat losing them.", - "inline": false - } - ] - }, - - { - "title": "Less time in the car!", - "image": {"url": "https://www.careeraddict.com/uploads/article/55294/businessman-riding-bike.jpg"}, - "footer": {"text": "Help out by sharing this information!"}, - "fields": [ - { - "name": "The problem", - "value": "Every mile you drive to work produces about a pound of C0₂. That's crazy! What's crazier is how clean the planet could be if we spent less time in the car!", - "inline": false - }, - - { - "name": "What you can do", - "value": "Instead of using your car, ride your bike if possible! Not only does it save that pound of C0₂, it is also great exercise and is cheaper!", - "inline": false - } - ] - }, - - { - "title":"Paint your roof white!", - "image": {"url": "https://modernize.com/wp-content/uploads/2016/10/Cool-roof.jpg"}, - "footer": {"text":"Help out by sharing this information!"}, - "fields": [ - { - "name": "The problem", - "value": "People with dark roofs often spend 20 to 40% more on their electricity bills because of the extra heat, which means more electricity needs to be made, and a lot of it isn't renewable.", - "inline": false - }, - - { - "name":"What you can do", - "value": "Having a light colored roof will save you money, and also researchers at the Lawrence Berkeley National Laboratory estimated that if 80 percent of roofs in tropical and temperate climate areas were painted white, it could offset the greenhouse gas emissions of 300 million automobiles around the world.", - "inline": false - } - ] - } -] diff --git a/bot/resources/easter/traditions.json b/bot/resources/easter/traditions.json deleted file mode 100644 index f9dd6d81..00000000 --- a/bot/resources/easter/traditions.json +++ /dev/null @@ -1,13 +0,0 @@ -{"England": "Easter in England is celebrated through the exchange of Easter Eggs and other gifts like clothes, chocolates or holidays packages. Easter bonnets or baskets are also made that have fillings like daffodils in them.", -"Haiti": "In Haiti, kids have the freedom to spend Good Friday playing outdoors. On this day colourful kites fill the sky and children run long distances, often barefoot, trying to get their kite higher than their friends.", -"Indonesia": "Slightly unconventional, but kids in Indonesia celebrate Easter with a tooth brushing competition!", -"Ethipoia": "In Ethiopia, Easter is called Fasika and marks the end of a 55-day fast during which Christians have only eaten one vegetarian meal a day. Ethiopians will often break their fast after church by eating injera (a type of bread) or teff pancakes, made from grass flour.", -"El Salvador": "On Good Friday communities make rug-like paintings on the streets with sand and sawdust. These later become the path for processions and main avenues and streets are closed", -"Ghana": "Ghanaians dress in certain colours to mark the different days of Easter. On Good Friday, depending on the church denomination, men and women will either dress in dark mourning clothes or bright colours. On Easter Sunday everyone wears white.", -"Kenya": "On Easter Sunday, kids in Kenya look forward to a sumptuous Easter meal after church (Easter services are known to last for three hours!). Children share Nyama Choma (roasted meat) and have a soft drink with their meal!", -"Guatemala": "In Guatemala, Easter customs include a large, colourful celebration marked by countless processions. The main roads are closed, and the sound of music rings through the streets. Special food is prepared such as curtido (a diced vegetable mix which is cooked in vinegar to achieve a sour taste), fish, eggs, chickpeas, fruit mix, pumpkin, pacaya palm and spondias fruit (a Spanish version of a plum.)", -"Germany": "In Germany, Easter is known by the name of Ostern. Easter holidays for children last for about three weeks. Good Friday, Easter Saturday and Easter Sunday are the days when people do not work at all.", -"Mexico": "Semana Santa and Pascua (two separate observances) form a part of Easter celebrations in Mexico. Semana Santa stands for the entire Holy Week, from Palm Sunday to Easter Saturday, whereas the Pascua is the observance of the period from the Resurrection Sunday to the following Saturday.", -"Poland": "They shape the Easter Butter Lamb (Baranek Wielkanocyny) from a chunk of butter. They attempt to make it look like a fluffy lamb!", -"Greece": "They burn an effigy of Judas Iscariot, the betrayer of Jesus, sometimes is done as part of a Passion Play! It is hung by the neck and then burnt.", -"Philippines": "Some Christians put themselves through the same pain that Christ endured, they have someone naile them to a cross and put a crown of thornes on their head."} diff --git a/bot/resources/holidays/earth_day/save_the_planet.json b/bot/resources/holidays/earth_day/save_the_planet.json new file mode 100644 index 00000000..f22261b7 --- /dev/null +++ b/bot/resources/holidays/earth_day/save_the_planet.json @@ -0,0 +1,77 @@ +[ + { + "title": "Choose renewable energy", + "image": {"url": "https://cdn.dnaindia.com/sites/default/files/styles/full/public/2019/07/23/851602-renewable-energy-istock-072419.jpg"}, + "footer": {"text": "Help out by sharing this information!"}, + "fields": [ + { + "name": "The problem", + "value": "Getting energy from oil or fossil fuels isn't a good idea, because there is only so much of it.", + "inline": false + }, + + { + "name": "What you can do", + "value": "Use renewable energy, such as wind, solar, and hydro, because it is healthier and is not a finite resource!", + "inline": false + } + ] + }, + + { + "title": "Save the trees!", + "image": {"url": "https://www.thecollegesolution.com/wp-content/uploads/2014/07/crumpled-paper-1.jpg"}, + "footer": {"text": "Help out by sharing this information!"}, + "fields": [ + { + "name": "The problem", + "value": "We often waste trees on making paper, and just getting rid of them for no good reason.", + "inline": false + }, + + { + "name": "What you can do", + "value": "Make sure you only use paper when absolutely necessary. When you do, make sure to use recycled paper because making new paper causes pollution. Find ways to plant trees (Hacktober Fest!) to combat losing them.", + "inline": false + } + ] + }, + + { + "title": "Less time in the car!", + "image": {"url": "https://www.careeraddict.com/uploads/article/55294/businessman-riding-bike.jpg"}, + "footer": {"text": "Help out by sharing this information!"}, + "fields": [ + { + "name": "The problem", + "value": "Every mile you drive to work produces about a pound of C0₂. That's crazy! What's crazier is how clean the planet could be if we spent less time in the car!", + "inline": false + }, + + { + "name": "What you can do", + "value": "Instead of using your car, ride your bike if possible! Not only does it save that pound of C0₂, it is also great exercise and is cheaper!", + "inline": false + } + ] + }, + + { + "title":"Paint your roof white!", + "image": {"url": "https://modernize.com/wp-content/uploads/2016/10/Cool-roof.jpg"}, + "footer": {"text":"Help out by sharing this information!"}, + "fields": [ + { + "name": "The problem", + "value": "People with dark roofs often spend 20 to 40% more on their electricity bills because of the extra heat, which means more electricity needs to be made, and a lot of it isn't renewable.", + "inline": false + }, + + { + "name":"What you can do", + "value": "Having a light colored roof will save you money, and also researchers at the Lawrence Berkeley National Laboratory estimated that if 80 percent of roofs in tropical and temperate climate areas were painted white, it could offset the greenhouse gas emissions of 300 million automobiles around the world.", + "inline": false + } + ] + } +] diff --git a/bot/resources/holidays/easter/april_fools_vids.json b/bot/resources/holidays/easter/april_fools_vids.json new file mode 100644 index 00000000..e1e8c70a --- /dev/null +++ b/bot/resources/holidays/easter/april_fools_vids.json @@ -0,0 +1,130 @@ +[ + { + "url": "https://youtu.be/OYcv406J_J4", + "channel": "google" + }, + { + "url": "https://youtu.be/0_5X6N6DHyk", + "channel": "google" + }, + { + "url": "https://youtu.be/UmJ2NBHXTqo", + "channel": "google" + }, + { + "url": "https://youtu.be/3MA6_21nka8", + "channel": "google" + }, + { + "url": "https://youtu.be/QAwL0O5nXe0", + "channel": "google" + }, + { + "url": "https://youtu.be/DPEJB-FCItk", + "channel": "google" + }, + { + "url": "https://youtu.be/LSZPNwZex9s", + "channel": "google" + }, + { + "url": "https://youtu.be/dFrgNiweQDk", + "channel": "google" + }, + { + "url": "https://youtu.be/F0F6SnbqUcE", + "channel": "google" + }, + { + "url": "https://youtu.be/VkOuShXpoKc", + "channel": "google" + }, + { + "url": "https://youtu.be/HQtGFBbwKEk", + "channel": "google" + }, + { + "url": "https://youtu.be/Cp10_PygJ4o", + "channel": "google" + }, + { + "url": "https://youtu.be/XTTtkisylQw", + "channel": "google" + }, + { + "url": "https://youtu.be/hydLZJXG3Tk", + "channel": "google" + }, + { + "url": "https://youtu.be/U2JBFlW--UU", + "channel": "google" + }, + { + "url": "https://youtu.be/G3NXNnoGr3Y", + "channel": "google" + }, + { + "url": "https://youtu.be/4YMD6xELI_k", + "channel": "google" + }, + { + "url": "https://youtu.be/qcgWRpQP6ds", + "channel": "google" + }, + { + "url": "https://youtu.be/Zr4JwPb99qU", + "channel": "google" + }, + { + "url": "https://youtu.be/VFbYadm_mrw", + "channel": "google" + }, + { + "url": "https://youtu.be/_qFFHC0eIUc", + "channel": "google" + }, + { + "url": "https://youtu.be/H542nLTTbu0", + "channel": "google" + }, + { + "url": "https://youtu.be/Je7Xq9tdCJc", + "channel": "google" + }, + { + "url": "https://youtu.be/re0VRK6ouwI", + "channel": "google" + }, + { + "url": "https://youtu.be/1KhZKNZO8mQ", + "channel": "google" + }, + { + "url": "https://youtu.be/UiLSiqyDf4Y", + "channel": "google" + }, + { + "url": "https://youtu.be/rznYifPHxDg", + "channel": "google" + }, + { + "url": "https://youtu.be/blB_X38YSxQ", + "channel": "google" + }, + { + "url": "https://youtu.be/Bu927_ul_X0", + "channel": "google" + }, + { + "url": "https://youtu.be/smM-Wdk2RLQ", + "channel": "nvidia" + }, + { + "url": "https://youtu.be/IlCx5gjAmqI", + "channel": "razer" + }, + { + "url": "https://youtu.be/j8UJE7DoyJ8", + "channel": "razer" + } +] diff --git a/bot/resources/holidays/easter/bunny_names.json b/bot/resources/holidays/easter/bunny_names.json new file mode 100644 index 00000000..8c97169c --- /dev/null +++ b/bot/resources/holidays/easter/bunny_names.json @@ -0,0 +1,29 @@ +{ + "names": [ + "Flopsy", + "Hopsalot", + "Thumper", + "Nibbles", + "Daisy", + "Fuzzy", + "Cottontail", + "Carrot Top", + "Marshmallow", + "Lucky", + "Clover", + "Daffodil", + "Buttercup", + "Goldie", + "Dizzy", + "Trixie", + "Snuffles", + "Hopscotch", + "Skipper", + "Thunderfoot", + "Bigwig", + "Dandelion", + "Pipkin", + "Buckthorn", + "Skipper" + ] +} diff --git a/bot/resources/holidays/easter/chocolate_bunny.png b/bot/resources/holidays/easter/chocolate_bunny.png new file mode 100644 index 00000000..6b25aa5a Binary files /dev/null and b/bot/resources/holidays/easter/chocolate_bunny.png differ diff --git a/bot/resources/holidays/easter/easter_egg_facts.json b/bot/resources/holidays/easter/easter_egg_facts.json new file mode 100644 index 00000000..b0650b84 --- /dev/null +++ b/bot/resources/holidays/easter/easter_egg_facts.json @@ -0,0 +1,17 @@ +[ + "The first story of a rabbit (later named the \"Easter Bunny\") hiding eggs in a garden was published in 1680.", + "Rabbits are known to be prolific pro creators and are an ancient symbol of fertility and new life. The German immigrants brought the tale of Easter Bunny in the 1700s with the tradition of an egg-laying hare called \"Osterhase\". The kids then would make nests in which the creature would lay coloured eggs. The tradition has been revolutionized in the form of candies and gifts instead of eggs.", + "In earlier days, a festival of egg throwing was held in church, when the priest would throw a hard-boiled egg to one of the choirboys. It was then tossed from one choirboy to the next and whoever held the egg when the clock struck 12 on Easter, was the winner and could keep it.", + "In medieval times, Easter eggs were boiled with onions to give them a golden sheen. Edward I went beyond this tradition in 1290 and ordered 450 eggs to be covered in gold leaf and given as Easter gifts.", + "Decorating Easter eggs is an ancient tradition that dates back to 13th century. One of the explanations for this custom is that eggs were considered as a forbidden food during the Lenten season (40 days before Easter). Therefore, people would paint and decorate them to mark an end of the period of penance and fasting and later eat them on Easter. The tradition of decorating eggs is called Pysanka which is creating a traditional Ukrainian folk design using wax-resist method.", + "Members of the Greek Orthodox faith often paint their Easter eggs red, which symbolizes Jesus' blood and his victory over death. The color red, symbolizes renewal of life, such as, Jesus' resurrection.", + "Eggs rolling take place in many parts of the world which symbolizes stone which was rolled away from the tomb where Jesus' body was laid after his death.", + "Easter eggs have been considered as a symbol of fertility, rebirth and new life. The custom of giving eggs has been derived from Egyptians, Persians, Gauls, Greeks, and Romans.", + "The first chocolate Easter egg was made by Fry's in 1873. Before this, people would give hollow cardboard eggs, filled with gifts.", + "The tallest chocolate Easter egg was made in Italy in 2011. Standing 10.39 metres tall and weighing 7,200 kg, it was taller than a giraffe and heavier than an elephant.", + "The largest ever Easter egg hunt was in Florida, where 9,753 children searched for 501,000 eggs.", + "In 2007, an Easter egg covered in diamonds sold for almost £9 million. Every hour, a cockerel made of jewels pops up from the top of the Faberge egg, flaps its wings four times, nods its head three times and makes a crowing noise. The gold-and-pink enamel egg was made by the Russian royal family as an engagement gift for French aristocrat Baron Edouard de Rothschild.", + "The White House held their first official egg roll in 1878 when Rutherford B. Hayes was the President. It is a race in which children push decorated, hard-boiled eggs across the White House lawn as an annual event held the Monday after Easter. In 2009, the Obamas hosted their first Easter egg roll with the theme, \"Let's go play\" which was meant to encourage young people to lead healthy and active lives.", + "80 million chocolate Easter eggs are sold each year. This accounts for 10% of Britain's annual spending on chocolate!", + "John Cadbury soon followed suit and made his first Cadbury Easter egg in 1875. By 1892 the company was producing 19 different lines, all made from dark chocolate." +] diff --git a/bot/resources/holidays/easter/easter_eggs/design1.png b/bot/resources/holidays/easter/easter_eggs/design1.png new file mode 100644 index 00000000..d887c590 Binary files /dev/null and b/bot/resources/holidays/easter/easter_eggs/design1.png differ diff --git a/bot/resources/holidays/easter/easter_eggs/design2.png b/bot/resources/holidays/easter/easter_eggs/design2.png new file mode 100644 index 00000000..c4fff644 Binary files /dev/null and b/bot/resources/holidays/easter/easter_eggs/design2.png differ diff --git a/bot/resources/holidays/easter/easter_eggs/design3.png b/bot/resources/holidays/easter/easter_eggs/design3.png new file mode 100644 index 00000000..803bc1e3 Binary files /dev/null and b/bot/resources/holidays/easter/easter_eggs/design3.png differ diff --git a/bot/resources/holidays/easter/easter_eggs/design4.png b/bot/resources/holidays/easter/easter_eggs/design4.png new file mode 100644 index 00000000..38e6a83f Binary files /dev/null and b/bot/resources/holidays/easter/easter_eggs/design4.png differ diff --git a/bot/resources/holidays/easter/easter_eggs/design5.png b/bot/resources/holidays/easter/easter_eggs/design5.png new file mode 100644 index 00000000..56662c26 Binary files /dev/null and b/bot/resources/holidays/easter/easter_eggs/design5.png differ diff --git a/bot/resources/holidays/easter/easter_eggs/design6.png b/bot/resources/holidays/easter/easter_eggs/design6.png new file mode 100644 index 00000000..5372439a Binary files /dev/null and b/bot/resources/holidays/easter/easter_eggs/design6.png differ diff --git a/bot/resources/holidays/easter/easter_riddle.json b/bot/resources/holidays/easter/easter_riddle.json new file mode 100644 index 00000000..f7eb63d8 --- /dev/null +++ b/bot/resources/holidays/easter/easter_riddle.json @@ -0,0 +1,74 @@ +[ + { + "question": "What kind of music do bunnies like?", + "riddles": [ + "Two words", + "Jump to the beat" + ], + "correct_answer": "Hip hop" + }, + { + "question": "What kind of jewelry do rabbits wear?", + "riddles": [ + "They can eat it too", + "14 ___ gold" + ], + "correct_answer": "14 carrot gold" + }, + { + "question": "What does the easter bunny get for making a basket?", + "riddles": [ + "KOBE!", + "1+1 = ?" + ], + "correct_answer": "2 points" + }, + { + "question": "Where does the easter bunny eat breakfast?", + "riddles": [ + "No waffles here", + "An international home" + ], + "correct_answer": "IHOP" + }, + { + "question": "What do you call a rabbit with fleas?", + "riddles": [ + "A bit of a looney tune", + "What's up Doc?" + ], + "correct_answer": "Bugs Bunny" + }, + { + "question": "Why was the little girl sad after the race?", + "riddles": [ + "2nd place?", + "Who beat her?" + ], + "correct_answer": "Because an egg beater" + }, + { + "question": "What happened to the Easter Bunny when he misbehaved at school?", + "riddles": [ + "Won't be back anymore", + "Worse than suspension" + ], + "correct_answer": "He was eggspelled" + }, + { + "question": "What kind of bunny can't hop?", + "riddles": [ + "Might melt in the sun", + "Fragile and yummy" + ], + "correct_answer": "A chocolate one" + }, + { + "question": "Why did the Easter Bunny have to fire the duck?", + "riddles": [ + "Quack", + "MY EGGS!!" + ], + "correct_answer": "He kept quacking the eggs" + } +] diff --git a/bot/resources/holidays/easter/egghead_questions.json b/bot/resources/holidays/easter/egghead_questions.json new file mode 100644 index 00000000..5535f8e0 --- /dev/null +++ b/bot/resources/holidays/easter/egghead_questions.json @@ -0,0 +1,181 @@ +[ + { + "question": "Where did the idea of the Easter Bunny originate?", + "answers": [ + "Russia", + "The United States", + "The UK", + "Germany" + ], + "correct_answer": 3 + }, + { + "question": "The Easter Bunny was originally going to be a...", + "answers": [ + "hare", + "possum", + "cat", + "dove" + ], + "correct_answer": 0 + }, + { + "question": "Which of the following is NOT a movie about Easter?", + "answers": [ + "Winnie the Pooh - Springtime with Roo", + "It's a Wonderful Life", + "The Passion of the Christ", + "Here Comes Peter Cottontail" + ], + "correct_answer": 1 + }, + { + "question": "In Australia, what animal is used instead of the Easter Bunny?", + "answers": [ + "kangaroo", + "wombat", + "koala", + "bilby" + ], + "correct_answer": 3 + }, + { + "question": "When was the first Earth Day?", + "answers": [ + "1982", + "2003", + "1999", + "1970" + ], + "correct_answer": 3 + }, + { + "question": "Who is considered to be the founder of Earth Day?", + "answers": [ + "President Jimmy Carter", + "President John F. Kennedy", + "Vice President Al Gore", + "Senator Gaylord Nelson" + ], + "correct_answer": 3 + }, + { + "question": "Approximately how many countries participated in Earth Day 2000?", + "answers": [ + "60", + "140", + "180", + "240" + ], + "correct_answer": 2 + }, + { + "question": "As Earth Day is this month, how old is the Earth?", + "answers": [ + "4.5 billion years old", + "5 million years old", + "10 billion years old", + "6.7 billion years old" + ], + "correct_answer": 0 + }, + { + "question": "As a celebration of Earth Day, what is the percentage of Oxygen in the Earth's atmosphere?", + "answers": [ + "18%", + "21%", + "25%", + "31%" + ], + "correct_answer": 1 + }, + { + "question": "In what year did Google begin its tradition of April Fools Jokes?", + "answers": [ + "1997", + "2000", + "2003", + "2007" + ], + "correct_answer": 1 + }, + { + "question": "Which type of chocolate is the most healthy?", + "answers": [ + "Dark", + "White", + "Milk" + ], + "correct_answer": 0 + }, + { + "question": "How many bars of milk chocolate would you have to eat to get the same amount of caffeine as in one cup of coffee?", + "answers": [ + "3", + "9", + "14", + "20" + ], + "correct_answer": 2 + }, + { + "question": "Aztecs used to use one of the ingedients of chocolate, cocoa beans, as...", + "answers": [ + "currency", + "medicine", + "dye", + "fertilizer" + ], + "correct_answer": 0 + }, + { + "question": "Which European country was the first to enjoy chocolate?", + "answers": [ + "France", + "Spain", + "England", + "Switzerland" + ], + "correct_answer": 1 + }, + { + "question": "The first European Chocolate Shop opened in what city in 1657?", + "answers": [ + "Paris, France", + "Madrid, Spain", + "Zürich, Switzerland", + "London, England" + ], + "correct_answer": 3 + }, + { + "question": "On average, how many eggs does a hen lay in a year?", + "answers": [ + "Between 200-230", + "Between 250-270", + "Between 300-330", + "Between 370-400" + ], + "correct_answer": 1 + }, + { + "question": "What determines the colour of an egg yolk?", + "answers": [ + "The size of the hen", + "The age of a hen", + "The diet of a hen", + "The colour of a hen's feathers" + ], + "correct_answer": 2 + }, + { + "question": "What country produces the most eggs in a year?", + "answers": [ + "China", + "India", + "The United States", + "Japan" + ], + "correct_answer": 0 + } +] diff --git a/bot/resources/holidays/easter/traditions.json b/bot/resources/holidays/easter/traditions.json new file mode 100644 index 00000000..f9dd6d81 --- /dev/null +++ b/bot/resources/holidays/easter/traditions.json @@ -0,0 +1,13 @@ +{"England": "Easter in England is celebrated through the exchange of Easter Eggs and other gifts like clothes, chocolates or holidays packages. Easter bonnets or baskets are also made that have fillings like daffodils in them.", +"Haiti": "In Haiti, kids have the freedom to spend Good Friday playing outdoors. On this day colourful kites fill the sky and children run long distances, often barefoot, trying to get their kite higher than their friends.", +"Indonesia": "Slightly unconventional, but kids in Indonesia celebrate Easter with a tooth brushing competition!", +"Ethipoia": "In Ethiopia, Easter is called Fasika and marks the end of a 55-day fast during which Christians have only eaten one vegetarian meal a day. Ethiopians will often break their fast after church by eating injera (a type of bread) or teff pancakes, made from grass flour.", +"El Salvador": "On Good Friday communities make rug-like paintings on the streets with sand and sawdust. These later become the path for processions and main avenues and streets are closed", +"Ghana": "Ghanaians dress in certain colours to mark the different days of Easter. On Good Friday, depending on the church denomination, men and women will either dress in dark mourning clothes or bright colours. On Easter Sunday everyone wears white.", +"Kenya": "On Easter Sunday, kids in Kenya look forward to a sumptuous Easter meal after church (Easter services are known to last for three hours!). Children share Nyama Choma (roasted meat) and have a soft drink with their meal!", +"Guatemala": "In Guatemala, Easter customs include a large, colourful celebration marked by countless processions. The main roads are closed, and the sound of music rings through the streets. Special food is prepared such as curtido (a diced vegetable mix which is cooked in vinegar to achieve a sour taste), fish, eggs, chickpeas, fruit mix, pumpkin, pacaya palm and spondias fruit (a Spanish version of a plum.)", +"Germany": "In Germany, Easter is known by the name of Ostern. Easter holidays for children last for about three weeks. Good Friday, Easter Saturday and Easter Sunday are the days when people do not work at all.", +"Mexico": "Semana Santa and Pascua (two separate observances) form a part of Easter celebrations in Mexico. Semana Santa stands for the entire Holy Week, from Palm Sunday to Easter Saturday, whereas the Pascua is the observance of the period from the Resurrection Sunday to the following Saturday.", +"Poland": "They shape the Easter Butter Lamb (Baranek Wielkanocyny) from a chunk of butter. They attempt to make it look like a fluffy lamb!", +"Greece": "They burn an effigy of Judas Iscariot, the betrayer of Jesus, sometimes is done as part of a Passion Play! It is hung by the neck and then burnt.", +"Philippines": "Some Christians put themselves through the same pain that Christ endured, they have someone naile them to a cross and put a crown of thornes on their head."} -- cgit v1.2.3 From 3377e46dc890626a29b05e134799b6219598107b Mon Sep 17 00:00:00 2001 From: Janine vN Date: Sat, 4 Sep 2021 23:31:14 -0400 Subject: Move Halloween to Holidays folder Moves all the hallowen features to the holidays folder. Also updates the paths to reflect the folder moves. --- bot/exts/halloween/8ball.py | 31 - bot/exts/halloween/candy_collection.py | 203 -- bot/exts/halloween/halloween_facts.py | 55 - bot/exts/halloween/halloweenify.py | 64 - bot/exts/halloween/monsterbio.py | 54 - bot/exts/halloween/monstersurvey.py | 205 -- bot/exts/halloween/scarymovie.py | 124 -- bot/exts/halloween/spookygif.py | 38 - bot/exts/halloween/spookynamerate.py | 391 ---- bot/exts/halloween/spookyrating.py | 65 - bot/exts/halloween/spookyreact.py | 70 - bot/exts/holidays/halloween/8ball.py | 31 + bot/exts/holidays/halloween/__init__.py | 0 bot/exts/holidays/halloween/candy_collection.py | 203 ++ bot/exts/holidays/halloween/halloween_facts.py | 55 + bot/exts/holidays/halloween/halloweenify.py | 64 + bot/exts/holidays/halloween/monsterbio.py | 54 + bot/exts/holidays/halloween/monstersurvey.py | 205 ++ bot/exts/holidays/halloween/scarymovie.py | 124 ++ bot/exts/holidays/halloween/spookygif.py | 38 + bot/exts/holidays/halloween/spookynamerate.py | 391 ++++ bot/exts/holidays/halloween/spookyrating.py | 67 + bot/exts/holidays/halloween/spookyreact.py | 70 + bot/resources/halloween/bat-clipart.png | Bin 12313 -> 0 bytes bot/resources/halloween/bloody-pentagram.png | Bin 7006 -> 0 bytes bot/resources/halloween/halloween_facts.json | 14 - bot/resources/halloween/halloweenify.json | 82 - bot/resources/halloween/monster.json | 41 - bot/resources/halloween/monstersurvey.json | 28 - bot/resources/halloween/responses.json | 14 - bot/resources/halloween/spooky_rating.json | 47 - bot/resources/halloween/spookynamerate_names.json | 2206 -------------------- bot/resources/halloween/spookyrating/baby.jpeg | Bin 110346 -> 0 bytes bot/resources/halloween/spookyrating/candle.jpeg | Bin 45981 -> 0 bytes bot/resources/halloween/spookyrating/clown.jpeg | Bin 53035 -> 0 bytes bot/resources/halloween/spookyrating/costume.jpeg | Bin 88629 -> 0 bytes bot/resources/halloween/spookyrating/devil.jpeg | Bin 336208 -> 0 bytes bot/resources/halloween/spookyrating/ghost.jpeg | Bin 29635 -> 0 bytes .../halloween/spookyrating/jackolantern.jpeg | Bin 17598 -> 0 bytes .../halloween/spookyrating/necromancer.jpeg | Bin 139672 -> 0 bytes bot/resources/halloween/spookyrating/tiger.jpeg | Bin 52851 -> 0 bytes bot/resources/holidays/halloween/bat-clipart.png | Bin 0 -> 12313 bytes .../holidays/halloween/bloody-pentagram.png | Bin 0 -> 7006 bytes .../holidays/halloween/halloween_facts.json | 14 + bot/resources/holidays/halloween/halloweenify.json | 82 + bot/resources/holidays/halloween/monster.json | 41 + .../holidays/halloween/monstersurvey.json | 28 + bot/resources/holidays/halloween/responses.json | 14 + .../holidays/halloween/spooky_rating.json | 47 + .../holidays/halloween/spookynamerate_names.json | 2206 ++++++++++++++++++++ .../holidays/halloween/spookyrating/baby.jpeg | Bin 0 -> 110346 bytes .../holidays/halloween/spookyrating/candle.jpeg | Bin 0 -> 45981 bytes .../holidays/halloween/spookyrating/clown.jpeg | Bin 0 -> 53035 bytes .../holidays/halloween/spookyrating/costume.jpeg | Bin 0 -> 88629 bytes .../holidays/halloween/spookyrating/devil.jpeg | Bin 0 -> 336208 bytes .../holidays/halloween/spookyrating/ghost.jpeg | Bin 0 -> 29635 bytes .../halloween/spookyrating/jackolantern.jpeg | Bin 0 -> 17598 bytes .../halloween/spookyrating/necromancer.jpeg | Bin 0 -> 139672 bytes .../holidays/halloween/spookyrating/tiger.jpeg | Bin 0 -> 52851 bytes 59 files changed, 3734 insertions(+), 3732 deletions(-) delete mode 100644 bot/exts/halloween/8ball.py delete mode 100644 bot/exts/halloween/candy_collection.py delete mode 100644 bot/exts/halloween/halloween_facts.py delete mode 100644 bot/exts/halloween/halloweenify.py delete mode 100644 bot/exts/halloween/monsterbio.py delete mode 100644 bot/exts/halloween/monstersurvey.py delete mode 100644 bot/exts/halloween/scarymovie.py delete mode 100644 bot/exts/halloween/spookygif.py delete mode 100644 bot/exts/halloween/spookynamerate.py delete mode 100644 bot/exts/halloween/spookyrating.py delete mode 100644 bot/exts/halloween/spookyreact.py create mode 100644 bot/exts/holidays/halloween/8ball.py create mode 100644 bot/exts/holidays/halloween/__init__.py create mode 100644 bot/exts/holidays/halloween/candy_collection.py create mode 100644 bot/exts/holidays/halloween/halloween_facts.py create mode 100644 bot/exts/holidays/halloween/halloweenify.py create mode 100644 bot/exts/holidays/halloween/monsterbio.py create mode 100644 bot/exts/holidays/halloween/monstersurvey.py create mode 100644 bot/exts/holidays/halloween/scarymovie.py create mode 100644 bot/exts/holidays/halloween/spookygif.py create mode 100644 bot/exts/holidays/halloween/spookynamerate.py create mode 100644 bot/exts/holidays/halloween/spookyrating.py create mode 100644 bot/exts/holidays/halloween/spookyreact.py delete mode 100644 bot/resources/halloween/bat-clipart.png delete mode 100644 bot/resources/halloween/bloody-pentagram.png delete mode 100644 bot/resources/halloween/halloween_facts.json delete mode 100644 bot/resources/halloween/halloweenify.json delete mode 100644 bot/resources/halloween/monster.json delete mode 100644 bot/resources/halloween/monstersurvey.json delete mode 100644 bot/resources/halloween/responses.json delete mode 100644 bot/resources/halloween/spooky_rating.json delete mode 100644 bot/resources/halloween/spookynamerate_names.json delete mode 100644 bot/resources/halloween/spookyrating/baby.jpeg delete mode 100644 bot/resources/halloween/spookyrating/candle.jpeg delete mode 100644 bot/resources/halloween/spookyrating/clown.jpeg delete mode 100644 bot/resources/halloween/spookyrating/costume.jpeg delete mode 100644 bot/resources/halloween/spookyrating/devil.jpeg delete mode 100644 bot/resources/halloween/spookyrating/ghost.jpeg delete mode 100644 bot/resources/halloween/spookyrating/jackolantern.jpeg delete mode 100644 bot/resources/halloween/spookyrating/necromancer.jpeg delete mode 100644 bot/resources/halloween/spookyrating/tiger.jpeg create mode 100644 bot/resources/holidays/halloween/bat-clipart.png create mode 100644 bot/resources/holidays/halloween/bloody-pentagram.png create mode 100644 bot/resources/holidays/halloween/halloween_facts.json create mode 100644 bot/resources/holidays/halloween/halloweenify.json create mode 100644 bot/resources/holidays/halloween/monster.json create mode 100644 bot/resources/holidays/halloween/monstersurvey.json create mode 100644 bot/resources/holidays/halloween/responses.json create mode 100644 bot/resources/holidays/halloween/spooky_rating.json create mode 100644 bot/resources/holidays/halloween/spookynamerate_names.json create mode 100644 bot/resources/holidays/halloween/spookyrating/baby.jpeg create mode 100644 bot/resources/holidays/halloween/spookyrating/candle.jpeg create mode 100644 bot/resources/holidays/halloween/spookyrating/clown.jpeg create mode 100644 bot/resources/holidays/halloween/spookyrating/costume.jpeg create mode 100644 bot/resources/holidays/halloween/spookyrating/devil.jpeg create mode 100644 bot/resources/holidays/halloween/spookyrating/ghost.jpeg create mode 100644 bot/resources/holidays/halloween/spookyrating/jackolantern.jpeg create mode 100644 bot/resources/holidays/halloween/spookyrating/necromancer.jpeg create mode 100644 bot/resources/holidays/halloween/spookyrating/tiger.jpeg (limited to 'bot') diff --git a/bot/exts/halloween/8ball.py b/bot/exts/halloween/8ball.py deleted file mode 100644 index a2431190..00000000 --- a/bot/exts/halloween/8ball.py +++ /dev/null @@ -1,31 +0,0 @@ -import asyncio -import json -import logging -import random -from pathlib import Path - -from discord.ext import commands - -from bot.bot import Bot - -log = logging.getLogger(__name__) - -RESPONSES = json.loads(Path("bot/resources/halloween/responses.json").read_text("utf8")) - - -class SpookyEightBall(commands.Cog): - """Spooky Eightball answers.""" - - @commands.command(aliases=("spooky8ball",)) - async def spookyeightball(self, ctx: commands.Context, *, question: str) -> None: - """Responds with a random response to a question.""" - choice = random.choice(RESPONSES["responses"]) - msg = await ctx.send(choice[0]) - if len(choice) > 1: - await asyncio.sleep(random.randint(2, 5)) - await msg.edit(content=f"{choice[0]} \n{choice[1]}") - - -def setup(bot: Bot) -> None: - """Load the Spooky Eight Ball Cog.""" - bot.add_cog(SpookyEightBall()) diff --git a/bot/exts/halloween/candy_collection.py b/bot/exts/halloween/candy_collection.py deleted file mode 100644 index 4afd5913..00000000 --- a/bot/exts/halloween/candy_collection.py +++ /dev/null @@ -1,203 +0,0 @@ -import logging -import random -from typing import Union - -import discord -from async_rediscache import RedisCache -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Channels, Month -from bot.utils.decorators import in_month - -log = logging.getLogger(__name__) - -# chance is 1 in x range, so 1 in 20 range would give 5% chance (for add candy) -ADD_CANDY_REACTION_CHANCE = 20 # 5% -ADD_CANDY_EXISTING_REACTION_CHANCE = 10 # 10% -ADD_SKULL_REACTION_CHANCE = 50 # 2% -ADD_SKULL_EXISTING_REACTION_CHANCE = 20 # 5% - -EMOJIS = dict( - CANDY="\N{CANDY}", - SKULL="\N{SKULL}", - MEDALS=( - "\N{FIRST PLACE MEDAL}", - "\N{SECOND PLACE MEDAL}", - "\N{THIRD PLACE MEDAL}", - "\N{SPORTS MEDAL}", - "\N{SPORTS MEDAL}", - ), -) - - -class CandyCollection(commands.Cog): - """Candy collection game Cog.""" - - # User candy amount records - candy_records = RedisCache() - - # Candy and skull messages mapping - candy_messages = RedisCache() - skull_messages = RedisCache() - - def __init__(self, bot: Bot): - self.bot = bot - - @in_month(Month.OCTOBER) - @commands.Cog.listener() - async def on_message(self, message: discord.Message) -> None: - """Randomly adds candy or skull reaction to non-bot messages in the Event channel.""" - # Ignore messages in DMs - if not message.guild: - return - # make sure its a human message - if message.author.bot: - return - # ensure it's hacktober channel - if message.channel.id != Channels.community_bot_commands: - return - - # do random check for skull first as it has the lower chance - if random.randint(1, ADD_SKULL_REACTION_CHANCE) == 1: - await self.skull_messages.set(message.id, "skull") - await message.add_reaction(EMOJIS["SKULL"]) - # check for the candy chance next - elif random.randint(1, ADD_CANDY_REACTION_CHANCE) == 1: - await self.candy_messages.set(message.id, "candy") - await message.add_reaction(EMOJIS["CANDY"]) - - @in_month(Month.OCTOBER) - @commands.Cog.listener() - async def on_reaction_add(self, reaction: discord.Reaction, user: Union[discord.User, discord.Member]) -> None: - """Add/remove candies from a person if the reaction satisfies criteria.""" - message = reaction.message - # check to ensure the reactor is human - if user.bot: - return - - # check to ensure it is in correct channel - if message.channel.id != Channels.community_bot_commands: - return - - # if its not a candy or skull, and it is one of 10 most recent messages, - # proceed to add a skull/candy with higher chance - if str(reaction.emoji) not in (EMOJIS["SKULL"], EMOJIS["CANDY"]): - recent_message_ids = map( - lambda m: m.id, - await self.hacktober_channel.history(limit=10).flatten() - ) - if message.id in recent_message_ids: - await self.reacted_msg_chance(message) - return - - if await self.candy_messages.get(message.id) == "candy" and str(reaction.emoji) == EMOJIS["CANDY"]: - await self.candy_messages.delete(message.id) - if await self.candy_records.contains(user.id): - await self.candy_records.increment(user.id) - else: - await self.candy_records.set(user.id, 1) - - elif await self.skull_messages.get(message.id) == "skull" and str(reaction.emoji) == EMOJIS["SKULL"]: - await self.skull_messages.delete(message.id) - - if prev_record := await self.candy_records.get(user.id): - lost = min(random.randint(1, 3), prev_record) - await self.candy_records.decrement(user.id, lost) - - if lost == prev_record: - await CandyCollection.send_spook_msg(user, message.channel, "all of your") - else: - await CandyCollection.send_spook_msg(user, message.channel, lost) - else: - await CandyCollection.send_no_candy_spook_message(user, message.channel) - else: - return # Skip saving - - await reaction.clear() - - async def reacted_msg_chance(self, message: discord.Message) -> None: - """ - Randomly add a skull or candy reaction to a message if there is a reaction there already. - - This event has a higher probability of occurring than a reaction add to a message without an - existing reaction. - """ - if random.randint(1, ADD_SKULL_EXISTING_REACTION_CHANCE) == 1: - await self.skull_messages.set(message.id, "skull") - await message.add_reaction(EMOJIS["SKULL"]) - - elif random.randint(1, ADD_CANDY_EXISTING_REACTION_CHANCE) == 1: - await self.candy_messages.set(message.id, "candy") - await message.add_reaction(EMOJIS["CANDY"]) - - @property - def hacktober_channel(self) -> discord.TextChannel: - """Get #hacktoberbot channel from its ID.""" - return self.bot.get_channel(id=Channels.community_bot_commands) - - @staticmethod - async def send_spook_msg( - author: discord.Member, channel: discord.TextChannel, candies: Union[str, int] - ) -> None: - """Send a spooky message.""" - e = discord.Embed(colour=author.colour) - e.set_author( - name="Ghosts and Ghouls and Jack o' lanterns at night; " - f"I took {candies} candies and quickly took flight." - ) - await channel.send(embed=e) - - @staticmethod - async def send_no_candy_spook_message( - author: discord.Member, - channel: discord.TextChannel - ) -> None: - """An alternative spooky message sent when user has no candies in the collection.""" - embed = discord.Embed(color=author.color) - embed.set_author( - name=( - "Ghosts and Ghouls and Jack o' lanterns at night; " - "I tried to take your candies but you had none to begin with!" - ) - ) - await channel.send(embed=embed) - - @in_month(Month.OCTOBER) - @commands.command() - async def candy(self, ctx: commands.Context) -> None: - """Get the candy leaderboard and save to JSON.""" - records = await self.candy_records.items() - - def generate_leaderboard() -> str: - top_sorted = sorted( - ((user_id, score) for user_id, score in records if score > 0), - key=lambda x: x[1], - reverse=True - ) - top_five = top_sorted[:5] - - return "\n".join( - f"{EMOJIS['MEDALS'][index]} <@{record[0]}>: {record[1]}" - for index, record in enumerate(top_five) - ) if top_five else "No Candies" - - e = discord.Embed(colour=discord.Colour.blurple()) - e.add_field( - name="Top Candy Records", - value=generate_leaderboard(), - inline=False - ) - e.add_field( - name="\u200b", - value="Candies will randomly appear on messages sent. " - "\nHit the candy when it appears as fast as possible to get the candy! " - "\nBut beware the ghosts...", - inline=False - ) - await ctx.send(embed=e) - - -def setup(bot: Bot) -> None: - """Load the Candy Collection Cog.""" - bot.add_cog(CandyCollection(bot)) diff --git a/bot/exts/halloween/halloween_facts.py b/bot/exts/halloween/halloween_facts.py deleted file mode 100644 index ba3b5d17..00000000 --- a/bot/exts/halloween/halloween_facts.py +++ /dev/null @@ -1,55 +0,0 @@ -import json -import logging -import random -from datetime import timedelta -from pathlib import Path - -import discord -from discord.ext import commands - -from bot.bot import Bot - -log = logging.getLogger(__name__) - -SPOOKY_EMOJIS = [ - "\N{BAT}", - "\N{DERELICT HOUSE BUILDING}", - "\N{EXTRATERRESTRIAL ALIEN}", - "\N{GHOST}", - "\N{JACK-O-LANTERN}", - "\N{SKULL}", - "\N{SKULL AND CROSSBONES}", - "\N{SPIDER WEB}", -] -PUMPKIN_ORANGE = 0xFF7518 -INTERVAL = timedelta(hours=6).total_seconds() - -FACTS = json.loads(Path("bot/resources/halloween/halloween_facts.json").read_text("utf8")) -FACTS = list(enumerate(FACTS)) - - -class HalloweenFacts(commands.Cog): - """A Cog for displaying interesting facts about Halloween.""" - - def random_fact(self) -> tuple[int, str]: - """Return a random fact from the loaded facts.""" - return random.choice(FACTS) - - @commands.command(name="spookyfact", aliases=("halloweenfact",), brief="Get the most recent Halloween fact") - async def get_random_fact(self, ctx: commands.Context) -> None: - """Reply with the most recent Halloween fact.""" - index, fact = self.random_fact() - embed = self._build_embed(index, fact) - await ctx.send(embed=embed) - - @staticmethod - def _build_embed(index: int, fact: str) -> discord.Embed: - """Builds a Discord embed from the given fact and its index.""" - emoji = random.choice(SPOOKY_EMOJIS) - title = f"{emoji} Halloween Fact #{index + 1}" - return discord.Embed(title=title, description=fact, color=PUMPKIN_ORANGE) - - -def setup(bot: Bot) -> None: - """Load the Halloween Facts Cog.""" - bot.add_cog(HalloweenFacts()) diff --git a/bot/exts/halloween/halloweenify.py b/bot/exts/halloween/halloweenify.py deleted file mode 100644 index 83cfbaa7..00000000 --- a/bot/exts/halloween/halloweenify.py +++ /dev/null @@ -1,64 +0,0 @@ -import logging -from json import loads -from pathlib import Path -from random import choice - -import discord -from discord.errors import Forbidden -from discord.ext import commands -from discord.ext.commands import BucketType - -from bot.bot import Bot - -log = logging.getLogger(__name__) - -HALLOWEENIFY_DATA = loads(Path("bot/resources/halloween/halloweenify.json").read_text("utf8")) - - -class Halloweenify(commands.Cog): - """A cog to change a invokers nickname to a spooky one!""" - - @commands.cooldown(1, 300, BucketType.user) - @commands.command() - async def halloweenify(self, ctx: commands.Context) -> None: - """Change your nickname into a much spookier one!""" - async with ctx.typing(): - # Choose a random character from our list we loaded above and set apart the nickname and image url. - character = choice(HALLOWEENIFY_DATA["characters"]) - nickname = "".join(nickname for nickname in character) - image = "".join(character[nickname] for nickname in character) - - # Build up a Embed - embed = discord.Embed() - embed.colour = discord.Colour.dark_orange() - embed.title = "Not spooky enough?" - embed.description = ( - f"**{ctx.author.display_name}** wasn't spooky enough for you? That's understandable, " - f"{ctx.author.display_name} isn't scary at all! " - "Let me think of something better. Hmm... I got it!\n\n " - ) - embed.set_image(url=image) - - if isinstance(ctx.author, discord.Member): - try: - await ctx.author.edit(nick=nickname) - embed.description += f"Your new nickname will be: \n:ghost: **{nickname}** :jack_o_lantern:" - - except Forbidden: # The bot doesn't have enough permission - embed.description += ( - f"Your new nickname should be: \n :ghost: **{nickname}** :jack_o_lantern: \n\n" - f"It looks like I cannot change your name, but feel free to change it yourself." - ) - - else: # The command has been invoked in DM - embed.description += ( - f"Your new nickname should be: \n :ghost: **{nickname}** :jack_o_lantern: \n\n" - f"Feel free to change it yourself, or invoke the command again inside the server." - ) - - await ctx.send(embed=embed) - - -def setup(bot: Bot) -> None: - """Load the Halloweenify Cog.""" - bot.add_cog(Halloweenify()) diff --git a/bot/exts/halloween/monsterbio.py b/bot/exts/halloween/monsterbio.py deleted file mode 100644 index 69e898cb..00000000 --- a/bot/exts/halloween/monsterbio.py +++ /dev/null @@ -1,54 +0,0 @@ -import json -import logging -import random -from pathlib import Path - -import discord -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Colours - -log = logging.getLogger(__name__) - -TEXT_OPTIONS = json.loads( - Path("bot/resources/halloween/monster.json").read_text("utf8") -) # Data for a mad-lib style generation of text - - -class MonsterBio(commands.Cog): - """A cog that generates a spooky monster biography.""" - - def generate_name(self, seeded_random: random.Random) -> str: - """Generates a name (for either monster species or monster name).""" - n_candidate_strings = seeded_random.randint(2, len(TEXT_OPTIONS["monster_type"])) - return "".join(seeded_random.choice(TEXT_OPTIONS["monster_type"][i]) for i in range(n_candidate_strings)) - - @commands.command(brief="Sends your monster bio!") - async def monsterbio(self, ctx: commands.Context) -> None: - """Sends a description of a monster.""" - seeded_random = random.Random(ctx.author.id) # Seed a local Random instance rather than the system one - - name = self.generate_name(seeded_random) - species = self.generate_name(seeded_random) - biography_text = seeded_random.choice(TEXT_OPTIONS["biography_text"]) - words = {"monster_name": name, "monster_species": species} - for key, value in biography_text.items(): - if key == "text": - continue - - options = seeded_random.sample(TEXT_OPTIONS[key], value) - words[key] = " ".join(options) - - embed = discord.Embed( - title=f"{name}'s Biography", - color=seeded_random.choice([Colours.orange, Colours.purple]), - description=biography_text["text"].format_map(words), - ) - - await ctx.send(embed=embed) - - -def setup(bot: Bot) -> None: - """Load the Monster Bio Cog.""" - bot.add_cog(MonsterBio()) diff --git a/bot/exts/halloween/monstersurvey.py b/bot/exts/halloween/monstersurvey.py deleted file mode 100644 index 96cda11e..00000000 --- a/bot/exts/halloween/monstersurvey.py +++ /dev/null @@ -1,205 +0,0 @@ -import json -import logging -import pathlib - -from discord import Embed -from discord.ext import commands -from discord.ext.commands import Bot, Cog, Context - -log = logging.getLogger(__name__) - -EMOJIS = { - "SUCCESS": u"\u2705", - "ERROR": u"\u274C" -} - - -class MonsterSurvey(Cog): - """ - Vote for your favorite monster. - - This Cog allows users to vote for their favorite listed monster. - - Users may change their vote, but only their current vote will be counted. - """ - - def __init__(self): - """Initializes values for the bot to use within the voting commands.""" - self.registry_path = pathlib.Path("bot", "resources", "halloween", "monstersurvey.json") - self.voter_registry = json.loads(self.registry_path.read_text("utf8")) - - def json_write(self) -> None: - """Write voting results to a local JSON file.""" - log.info("Saved Monster Survey Results") - self.registry_path.write_text(json.dumps(self.voter_registry, indent=2)) - - def cast_vote(self, id: int, monster: str) -> None: - """ - Cast a user's vote for the specified monster. - - If the user has already voted, their existing vote is removed. - """ - vr = self.voter_registry - for m in vr: - if id not in vr[m]["votes"] and m == monster: - vr[m]["votes"].append(id) - else: - if id in vr[m]["votes"] and m != monster: - vr[m]["votes"].remove(id) - - def get_name_by_leaderboard_index(self, n: int) -> str: - """Return the monster at the specified leaderboard index.""" - n = n - 1 - vr = self.voter_registry - top = sorted(vr, key=lambda k: len(vr[k]["votes"]), reverse=True) - name = top[n] if n >= 0 else None - return name - - @commands.group( - name="monster", - aliases=("mon",) - ) - async def monster_group(self, ctx: Context) -> None: - """The base voting command. If nothing is called, then it will return an embed.""" - if ctx.invoked_subcommand is None: - async with ctx.typing(): - default_embed = Embed( - title="Monster Voting", - color=0xFF6800, - description="Vote for your favorite monster!" - ) - default_embed.add_field( - name=".monster show monster_name(optional)", - value="Show a specific monster. If none is listed, it will give you an error with valid choices.", - inline=False - ) - default_embed.add_field( - name=".monster vote monster_name", - value="Vote for a specific monster. You get one vote, but can change it at any time.", - inline=False - ) - default_embed.add_field( - name=".monster leaderboard", - value="Which monster has the most votes? This command will tell you.", - inline=False - ) - default_embed.set_footer(text=f"Monsters choices are: {', '.join(self.voter_registry)}") - - await ctx.send(embed=default_embed) - - @monster_group.command( - name="vote" - ) - async def monster_vote(self, ctx: Context, name: str = None) -> None: - """ - Cast a vote for a particular monster. - - Displays a list of monsters that can be voted for if one is not specified. - """ - if name is None: - await ctx.invoke(self.monster_leaderboard) - return - - async with ctx.typing(): - # Check to see if user used a numeric (leaderboard) index to vote - try: - idx = int(name) - name = self.get_name_by_leaderboard_index(idx) - except ValueError: - name = name.lower() - - vote_embed = Embed( - name="Monster Voting", - color=0xFF6800 - ) - - m = self.voter_registry.get(name) - if m is None: - vote_embed.description = f"You cannot vote for {name} because it's not in the running." - vote_embed.add_field( - name="Use `.monster show {monster_name}` for more information on a specific monster", - value="or use `.monster vote {monster}` to cast your vote for said monster.", - inline=False - ) - vote_embed.add_field( - name="You may vote for or show the following monsters:", - value=", ".join(self.voter_registry.keys()) - ) - else: - self.cast_vote(ctx.author.id, name) - vote_embed.add_field( - name="Vote successful!", - value=f"You have successfully voted for {m['full_name']}!", - inline=False - ) - vote_embed.set_thumbnail(url=m["image"]) - vote_embed.set_footer(text="Please note that any previous votes have been removed.") - self.json_write() - - await ctx.send(embed=vote_embed) - - @monster_group.command( - name="show" - ) - async def monster_show(self, ctx: Context, name: str = None) -> None: - """Shows the named monster. If one is not named, it sends the default voting embed instead.""" - if name is None: - await ctx.invoke(self.monster_leaderboard) - return - - async with ctx.typing(): - # Check to see if user used a numeric (leaderboard) index to vote - try: - idx = int(name) - name = self.get_name_by_leaderboard_index(idx) - except ValueError: - name = name.lower() - - m = self.voter_registry.get(name) - if not m: - await ctx.send("That monster does not exist.") - await ctx.invoke(self.monster_vote) - return - - embed = Embed(title=m["full_name"], color=0xFF6800) - embed.add_field(name="Summary", value=m["summary"]) - embed.set_image(url=m["image"]) - embed.set_footer(text=f"To vote for this monster, type .monster vote {name}") - - await ctx.send(embed=embed) - - @monster_group.command( - name="leaderboard", - aliases=("lb",) - ) - async def monster_leaderboard(self, ctx: Context) -> None: - """Shows the current standings.""" - async with ctx.typing(): - vr = self.voter_registry - top = sorted(vr, key=lambda k: len(vr[k]["votes"]), reverse=True) - total_votes = sum(len(m["votes"]) for m in self.voter_registry.values()) - - embed = Embed(title="Monster Survey Leader Board", color=0xFF6800) - for rank, m in enumerate(top): - votes = len(vr[m]["votes"]) - percentage = ((votes / total_votes) * 100) if total_votes > 0 else 0 - embed.add_field( - name=f"{rank+1}. {vr[m]['full_name']}", - value=( - f"{votes} votes. {percentage:.1f}% of total votes.\n" - f"Vote for this monster by typing " - f"'.monster vote {m}'\n" - f"Get more information on this monster by typing " - f"'.monster show {m}'" - ), - inline=False - ) - - embed.set_footer(text="You can also vote by their rank number. '.monster vote {number}' ") - - await ctx.send(embed=embed) - - -def setup(bot: Bot) -> None: - """Load the Monster Survey Cog.""" - bot.add_cog(MonsterSurvey()) diff --git a/bot/exts/halloween/scarymovie.py b/bot/exts/halloween/scarymovie.py deleted file mode 100644 index 33659fd8..00000000 --- a/bot/exts/halloween/scarymovie.py +++ /dev/null @@ -1,124 +0,0 @@ -import logging -import random - -from discord import Embed -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Tokens -log = logging.getLogger(__name__) - - -class ScaryMovie(commands.Cog): - """Selects a random scary movie and embeds info into Discord chat.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @commands.command(name="scarymovie", alias=["smovie"]) - async def random_movie(self, ctx: commands.Context) -> None: - """Randomly select a scary movie and display information about it.""" - async with ctx.typing(): - selection = await self.select_movie() - movie_details = await self.format_metadata(selection) - - await ctx.send(embed=movie_details) - - async def select_movie(self) -> dict: - """Selects a random movie and returns a JSON of movie details from TMDb.""" - url = "https://api.themoviedb.org/3/discover/movie" - params = { - "api_key": Tokens.tmdb, - "with_genres": "27", - "vote_count.gte": "5", - "include_adult": "false" - } - headers = { - "Content-Type": "application/json;charset=utf-8" - } - - # Get total page count of horror movies - async with self.bot.http_session.get(url=url, params=params, headers=headers) as response: - data = await response.json() - total_pages = data.get("total_pages") - - # Get movie details from one random result on a random page - params["page"] = random.randint(1, total_pages) - async with self.bot.http_session.get(url=url, params=params, headers=headers) as response: - data = await response.json() - selection_id = random.choice(data.get("results")).get("id") - - # Get full details and credits - async with self.bot.http_session.get( - url=f"https://api.themoviedb.org/3/movie/{selection_id}", - params={"api_key": Tokens.tmdb, "append_to_response": "credits"} - ) as selection: - - return await selection.json() - - @staticmethod - async def format_metadata(movie: dict) -> Embed: - """Formats raw TMDb data to be embedded in Discord chat.""" - # Build the relevant URLs. - movie_id = movie.get("id") - poster_path = movie.get("poster_path") - tmdb_url = f"https://www.themoviedb.org/movie/{movie_id}" if movie_id else None - poster = f"https://image.tmdb.org/t/p/original{poster_path}" if poster_path else None - - # Get cast names - cast = [] - for actor in movie.get("credits", {}).get("cast", [])[:3]: - cast.append(actor.get("name")) - - # Get director name - director = movie.get("credits", {}).get("crew", []) - if director: - director = director[0].get("name") - - # Determine the spookiness rating - rating = "" - rating_count = movie.get("vote_average", 0) / 2 - - for _ in range(int(rating_count)): - rating += ":skull:" - if (rating_count % 1) >= .5: - rating += ":bat:" - - # Try to get year of release and runtime - year = movie.get("release_date", [])[:4] - runtime = movie.get("runtime") - runtime = f"{runtime} minutes" if runtime else None - - # Not all these attributes will always be present - movie_attributes = { - "Directed by": director, - "Starring": ", ".join(cast), - "Running time": runtime, - "Release year": year, - "Spookiness rating": rating, - } - - embed = Embed( - colour=0x01d277, - title=f"**{movie.get('title')}**", - url=tmdb_url, - description=movie.get("overview") - ) - - if poster: - embed.set_image(url=poster) - - # Add the attributes that we actually have data for, but not the others. - for name, value in movie_attributes.items(): - if value: - embed.add_field(name=name, value=value) - - embed.set_footer(text="This product uses the TMDb API but is not endorsed or certified by TMDb.") - embed.set_thumbnail(url="https://i.imgur.com/LtFtC8H.png") - - return embed - - -def setup(bot: Bot) -> None: - """Load the Scary Movie Cog.""" - bot.add_cog(ScaryMovie(bot)) diff --git a/bot/exts/halloween/spookygif.py b/bot/exts/halloween/spookygif.py deleted file mode 100644 index 9511d407..00000000 --- a/bot/exts/halloween/spookygif.py +++ /dev/null @@ -1,38 +0,0 @@ -import logging - -import discord -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Colours, Tokens - -log = logging.getLogger(__name__) - -API_URL = "http://api.giphy.com/v1/gifs/random" - - -class SpookyGif(commands.Cog): - """A cog to fetch a random spooky gif from the web!""" - - def __init__(self, bot: Bot): - self.bot = bot - - @commands.command(name="spookygif", aliases=("sgif", "scarygif")) - async def spookygif(self, ctx: commands.Context) -> None: - """Fetches a random gif from the GIPHY API and responds with it.""" - async with ctx.typing(): - params = {"api_key": Tokens.giphy, "tag": "halloween", "rating": "g"} - # Make a GET request to the Giphy API to get a random halloween gif. - async with self.bot.http_session.get(API_URL, params=params) as resp: - data = await resp.json() - url = data["data"]["image_url"] - - embed = discord.Embed(title="A spooooky gif!", colour=Colours.purple) - embed.set_image(url=url) - - await ctx.send(embed=embed) - - -def setup(bot: Bot) -> None: - """Spooky GIF Cog load.""" - bot.add_cog(SpookyGif(bot)) diff --git a/bot/exts/halloween/spookynamerate.py b/bot/exts/halloween/spookynamerate.py deleted file mode 100644 index 5c21ead7..00000000 --- a/bot/exts/halloween/spookynamerate.py +++ /dev/null @@ -1,391 +0,0 @@ -import asyncio -import json -import random -from collections import defaultdict -from datetime import datetime, timedelta -from logging import getLogger -from os import getenv -from pathlib import Path -from typing import Optional - -from async_rediscache import RedisCache -from discord import Embed, Reaction, TextChannel, User -from discord.colour import Colour -from discord.ext import tasks -from discord.ext.commands import Cog, Context, group - -from bot.bot import Bot -from bot.constants import Channels, Client, Colours, Month -from bot.utils.decorators import InMonthCheckFailure - -logger = getLogger(__name__) - -EMOJIS_VAL = { - "\N{Jack-O-Lantern}": 1, - "\N{Ghost}": 2, - "\N{Skull and Crossbones}": 3, - "\N{Zombie}": 4, - "\N{Face Screaming In Fear}": 5, -} -ADDED_MESSAGES = [ - "Let's see if you win?", - ":jack_o_lantern: SPOOKY :jack_o_lantern:", - "If you got it, haunt it.", - "TIME TO GET YOUR SPOOKY ON! :skull:", -] -PING = "<@{id}>" - -EMOJI_MESSAGE = "\n".join(f"- {emoji} {val}" for emoji, val in EMOJIS_VAL.items()) -HELP_MESSAGE_DICT = { - "title": "Spooky Name Rate", - "description": f"Help for the `{Client.prefix}spookynamerate` command", - "color": Colours.soft_orange, - "fields": [ - { - "name": "How to play", - "value": ( - "Everyday, the bot will post a random name, which you will need to spookify using your creativity.\n" - "You can rate each message according to how scary it is.\n" - "At the end of the day, the author of the message with most reactions will be the winner of the day.\n" - f"On a scale of 1 to {len(EMOJIS_VAL)}, the reactions order:\n" - f"{EMOJI_MESSAGE}" - ), - "inline": False, - }, - { - "name": "How do I add my spookified name?", - "value": f"Simply type `{Client.prefix}spookynamerate add my name`", - "inline": False, - }, - { - "name": "How do I *delete* my spookified name?", - "value": f"Simply type `{Client.prefix}spookynamerate delete`", - "inline": False, - }, - ], -} - -# The names are from https://www.mockaroo.com/ -NAMES = json.loads(Path("bot/resources/halloween/spookynamerate_names.json").read_text("utf8")) -FIRST_NAMES = NAMES["first_names"] -LAST_NAMES = NAMES["last_names"] - - -class SpookyNameRate(Cog): - """ - A game that asks the user to spookify or halloweenify a name that is given everyday. - - It sends a random name everyday. The user needs to try and spookify it to his best ability and - send that name back using the `spookynamerate add entry` command - """ - - # This cache stores the message id of each added word along with a dictionary which contains the name the author - # added, the author's id, and the author's score (which is 0 by default) - messages = RedisCache() - - # The data cache stores small information such as the current name that is going on and whether it is the first time - # the bot is running - data = RedisCache() - debug = getenv("SPOOKYNAMERATE_DEBUG", False) # Enable if you do not want to limit the commands to October or if - # you do not want to wait till 12 UTC. Note: if debug is enabled and you run `.cogs reload spookynamerate`, it - # will automatically start the scoring and announcing the result (without waiting for 12, so do not expect it to.). - # Also, it won't wait for the two hours (when the poll closes). - - def __init__(self, bot: Bot): - self.bot = bot - self.name = None - - self.bot.loop.create_task(self.load_vars()) - - self.first_time = None - self.poll = False - self.announce_name.start() - self.checking_messages = asyncio.Lock() - # Define an asyncio.Lock() to make sure the dictionary isn't changed - # when checking the messages for duplicate emojis' - - async def load_vars(self) -> None: - """Loads the variables that couldn't be loaded in __init__.""" - self.first_time = await self.data.get("first_time", True) - self.name = await self.data.get("name") - - @group(name="spookynamerate", invoke_without_command=True) - async def spooky_name_rate(self, ctx: Context) -> None: - """Get help on the Spooky Name Rate game.""" - await ctx.send(embed=Embed.from_dict(HELP_MESSAGE_DICT)) - - @spooky_name_rate.command(name="list", aliases=("all", "entries")) - async def list_entries(self, ctx: Context) -> None: - """Send all the entries up till now in a single embed.""" - await ctx.send(embed=await self.get_responses_list(final=False)) - - @spooky_name_rate.command(name="name") - async def tell_name(self, ctx: Context) -> None: - """Tell the current random name.""" - if not self.poll: - await ctx.send(f"The name is **{self.name}**") - return - - await ctx.send( - f"The name ~~is~~ was **{self.name}**. The poll has already started, so you cannot " - "add an entry." - ) - - @spooky_name_rate.command(name="add", aliases=("register",)) - async def add_name(self, ctx: Context, *, name: str) -> None: - """Use this command to add/register your spookified name.""" - if self.poll: - logger.info(f"{ctx.author} tried to add a name, but the poll had already started.") - await ctx.send("Sorry, the poll has started! You can try and participate in the next round though!") - return - - for data in (json.loads(user_data) for _, user_data in await self.messages.items()): - if data["author"] == ctx.author.id: - await ctx.send( - "But you have already added an entry! Type " - f"`{self.bot.command_prefix}spookynamerate " - "delete` to delete it, and then you can add it again" - ) - return - - elif data["name"] == name: - await ctx.send("TOO LATE. Someone has already added this name.") - return - - msg = await (await self.get_channel()).send(f"{ctx.author.mention} added the name {name!r}!") - - await self.messages.set( - msg.id, - json.dumps( - { - "name": name, - "author": ctx.author.id, - "score": 0, - } - ), - ) - - for emoji in EMOJIS_VAL: - await msg.add_reaction(emoji) - - logger.info(f"{ctx.author} added the name {name!r}") - - @spooky_name_rate.command(name="delete") - async def delete_name(self, ctx: Context) -> None: - """Delete the user's name.""" - if self.poll: - await ctx.send("You can't delete your name since the poll has already started!") - return - for message_id, data in await self.messages.items(): - data = json.loads(data) - - if ctx.author.id == data["author"]: - await self.messages.delete(message_id) - await ctx.send(f"Name deleted successfully ({data['name']!r})!") - return - - await ctx.send( - f"But you don't have an entry... :eyes: Type `{self.bot.command_prefix}spookynamerate add your entry`" - ) - - @Cog.listener() - async def on_reaction_add(self, reaction: Reaction, user: User) -> None: - """Ensures that each user adds maximum one reaction.""" - if user.bot or not await self.messages.contains(reaction.message.id): - return - - async with self.checking_messages: # Acquire the lock so that the dictionary isn't reset while iterating. - if reaction.emoji in EMOJIS_VAL: - # create a custom counter - reaction_counter = defaultdict(int) - for msg_reaction in reaction.message.reactions: - async for reaction_user in msg_reaction.users(): - if reaction_user == self.bot.user: - continue - reaction_counter[reaction_user] += 1 - - if reaction_counter[user] > 1: - await user.send( - "Sorry, you have already added a reaction, " - "please remove your reaction and try again." - ) - await reaction.remove(user) - return - - @tasks.loop(hours=24.0) - async def announce_name(self) -> None: - """Announces the name needed to spookify every 24 hours and the winner of the previous game.""" - if not self.in_allowed_month(): - return - - channel = await self.get_channel() - - if self.first_time: - await channel.send( - "Okkey... Welcome to the **Spooky Name Rate Game**! It's a relatively simple game.\n" - f"Everyday, a random name will be sent in <#{Channels.community_bot_commands}> " - "and you need to try and spookify it!\nRegister your name using " - f"`{self.bot.command_prefix}spookynamerate add spookified name`" - ) - - await self.data.set("first_time", False) - self.first_time = False - - else: - if await self.messages.items(): - await channel.send(embed=await self.get_responses_list(final=True)) - self.poll = True - if not SpookyNameRate.debug: - await asyncio.sleep(2 * 60 * 60) # sleep for two hours - - logger.info("Calculating score") - for message_id, data in await self.messages.items(): - data = json.loads(data) - - msg = await channel.fetch_message(message_id) - score = 0 - for reaction in msg.reactions: - reaction_value = EMOJIS_VAL.get(reaction.emoji, 0) # get the value of the emoji else 0 - score += reaction_value * (reaction.count - 1) # multiply by the num of reactions - # subtract one, since one reaction was done by the bot - - logger.debug(f"{self.bot.get_user(data['author'])} got a score of {score}") - data["score"] = score - await self.messages.set(message_id, json.dumps(data)) - - # Sort the winner messages - winner_messages = sorted( - ((msg_id, json.loads(usr_data)) for msg_id, usr_data in await self.messages.items()), - key=lambda x: x[1]["score"], - reverse=True, - ) - - winners = [] - for i, winner in enumerate(winner_messages): - winners.append(winner) - if len(winner_messages) > i + 1: - if winner_messages[i + 1][1]["score"] != winner[1]["score"]: - break - elif len(winner_messages) == (i + 1) + 1: # The next element is the last element - if winner_messages[i + 1][1]["score"] != winner[1]["score"]: - break - - # one iteration is complete - await channel.send("Today's Spooky Name Rate Game ends now, and the winner(s) is(are)...") - - async with channel.typing(): - await asyncio.sleep(1) # give the drum roll feel - - if not winners: # There are no winners (no participants) - await channel.send("Hmm... Looks like no one participated! :cry:") - return - - score = winners[0][1]["score"] - congratulations = "to all" if len(winners) > 1 else PING.format(id=winners[0][1]["author"]) - names = ", ".join(f'{win[1]["name"]} ({PING.format(id=win[1]["author"])})' for win in winners) - - # display winners, their names and scores - await channel.send( - f"Congratulations {congratulations}!\n" - f"You have a score of {score}!\n" - f"Your name{ 's were' if len(winners) > 1 else 'was'}:\n{names}" - ) - - # Send random party emojis - party = (random.choice([":partying_face:", ":tada:"]) for _ in range(random.randint(1, 10))) - await channel.send(" ".join(party)) - - async with self.checking_messages: # Acquire the lock to delete the messages - await self.messages.clear() # reset the messages - - # send the next name - self.name = f"{random.choice(FIRST_NAMES)} {random.choice(LAST_NAMES)}" - await self.data.set("name", self.name) - - await channel.send( - "Let's move on to the next name!\nAnd the next name is...\n" - f"**{self.name}**!\nTry to spookify that... :smirk:" - ) - - self.poll = False # accepting responses - - @announce_name.before_loop - async def wait_till_scheduled_time(self) -> None: - """Waits till the next day's 12PM if crossed it, otherwise waits till the same day's 12PM.""" - if SpookyNameRate.debug: - return - - now = datetime.utcnow() - if now.hour < 12: - twelve_pm = now.replace(hour=12, minute=0, second=0, microsecond=0) - time_left = twelve_pm - now - await asyncio.sleep(time_left.seconds) - return - - tomorrow_12pm = now + timedelta(days=1) - tomorrow_12pm = tomorrow_12pm.replace(hour=12, minute=0, second=0, microsecond=0) - await asyncio.sleep((tomorrow_12pm - now).seconds) - - async def get_responses_list(self, final: bool = False) -> Embed: - """Returns an embed containing the responses of the people.""" - channel = await self.get_channel() - - embed = Embed(color=Colour.red()) - - if await self.messages.items(): - if final: - embed.title = "Spooky Name Rate is about to end!" - embed.description = ( - "This Spooky Name Rate round is about to end in 2 hours! You can review " - "the entries below! Have you rated other's names?" - ) - else: - embed.title = "All the spookified names!" - embed.description = "See a list of all the entries entered by everyone!" - else: - embed.title = "No one has added an entry yet..." - - for message_id, data in await self.messages.items(): - data = json.loads(data) - - embed.add_field( - name=(self.bot.get_user(data["author"]) or await self.bot.fetch_user(data["author"])).name, - value=f"[{(data)['name']}](https://discord.com/channels/{Client.guild}/{channel.id}/{message_id})", - ) - - return embed - - async def get_channel(self) -> Optional[TextChannel]: - """Gets the sir-lancebot-channel after waiting until ready.""" - await self.bot.wait_until_ready() - channel = self.bot.get_channel( - Channels.community_bot_commands - ) or await self.bot.fetch_channel(Channels.community_bot_commands) - if not channel: - logger.warning("Bot is unable to get the #seasonalbot-commands channel. Please check the channel ID.") - return channel - - @staticmethod - def in_allowed_month() -> bool: - """Returns whether running in the limited month.""" - if SpookyNameRate.debug: - return True - - if not Client.month_override: - return datetime.utcnow().month == Month.OCTOBER - return Client.month_override == Month.OCTOBER - - def cog_check(self, ctx: Context) -> bool: - """A command to check whether the command is being called in October.""" - if not self.in_allowed_month(): - raise InMonthCheckFailure("You can only use these commands in October!") - return True - - def cog_unload(self) -> None: - """Stops the announce_name task.""" - self.announce_name.cancel() - - -def setup(bot: Bot) -> None: - """Load the SpookyNameRate Cog.""" - bot.add_cog(SpookyNameRate(bot)) diff --git a/bot/exts/halloween/spookyrating.py b/bot/exts/halloween/spookyrating.py deleted file mode 100644 index f566fac2..00000000 --- a/bot/exts/halloween/spookyrating.py +++ /dev/null @@ -1,65 +0,0 @@ -import bisect -import json -import logging -import random -from pathlib import Path - -import discord -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Colours - -log = logging.getLogger(__name__) - -data: dict[str, dict[str, str]] = json.loads(Path("bot/resources/halloween/spooky_rating.json").read_text("utf8")) -SPOOKY_DATA = sorted((int(key), value) for key, value in data.items()) - - -class SpookyRating(commands.Cog): - """A cog for calculating one's spooky rating.""" - - def __init__(self): - self.local_random = random.Random() - - @commands.command() - @commands.cooldown(rate=1, per=5, type=commands.BucketType.user) - async def spookyrating(self, ctx: commands.Context, who: discord.Member = None) -> None: - """ - Calculates the spooky rating of someone. - - Any user will always yield the same result, no matter who calls the command - """ - if who is None: - who = ctx.author - - # This ensures that the same result over multiple runtimes - self.local_random.seed(who.id) - spooky_percent = self.local_random.randint(1, 101) - - # We need the -1 due to how bisect returns the point - # see the documentation for further detail - # https://docs.python.org/3/library/bisect.html#bisect.bisect - index = bisect.bisect(SPOOKY_DATA, (spooky_percent,)) - 1 - - _, data = SPOOKY_DATA[index] - - embed = discord.Embed( - title=data["title"], - description=f"{who} scored {spooky_percent}%!", - color=Colours.orange - ) - embed.add_field( - name="A whisper from Satan", - value=data["text"] - ) - embed.set_thumbnail( - url=data["image"] - ) - - await ctx.send(embed=embed) - - -def setup(bot: Bot) -> None: - """Load the Spooky Rating Cog.""" - bot.add_cog(SpookyRating()) diff --git a/bot/exts/halloween/spookyreact.py b/bot/exts/halloween/spookyreact.py deleted file mode 100644 index 25e783f4..00000000 --- a/bot/exts/halloween/spookyreact.py +++ /dev/null @@ -1,70 +0,0 @@ -import logging -import re - -import discord -from discord.ext.commands import Cog - -from bot.bot import Bot -from bot.constants import Month -from bot.utils.decorators import in_month - -log = logging.getLogger(__name__) - -SPOOKY_TRIGGERS = { - "spooky": (r"\bspo{2,}ky\b", "\U0001F47B"), - "skeleton": (r"\bskeleton\b", "\U0001F480"), - "doot": (r"\bdo{2,}t\b", "\U0001F480"), - "pumpkin": (r"\bpumpkin\b", "\U0001F383"), - "halloween": (r"\bhalloween\b", "\U0001F383"), - "jack-o-lantern": (r"\bjack-o-lantern\b", "\U0001F383"), - "danger": (r"\bdanger\b", "\U00002620") -} - - -class SpookyReact(Cog): - """A cog that makes the bot react to message triggers.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @in_month(Month.OCTOBER) - @Cog.listener() - async def on_message(self, message: discord.Message) -> None: - """Triggered when the bot sees a message in October.""" - for name, trigger in SPOOKY_TRIGGERS.items(): - trigger_test = re.search(trigger[0], message.content.lower()) - if trigger_test: - # Check message for bot replies and/or command invocations - # Short circuit if they're found, logging is handled in _short_circuit_check - if await self._short_circuit_check(message): - return - else: - await message.add_reaction(trigger[1]) - log.info(f"Added {name!r} reaction to message ID: {message.id}") - - async def _short_circuit_check(self, message: discord.Message) -> bool: - """ - Short-circuit helper check. - - Return True if: - * author is the bot - * prefix is not None - """ - # Check for self reaction - if message.author == self.bot.user: - log.debug(f"Ignoring reactions on self message. Message ID: {message.id}") - return True - - # Check for command invocation - # Because on_message doesn't give a full Context object, generate one first - ctx = await self.bot.get_context(message) - if ctx.prefix: - log.debug(f"Ignoring reactions on command invocation. Message ID: {message.id}") - return True - - return False - - -def setup(bot: Bot) -> None: - """Load the Spooky Reaction Cog.""" - bot.add_cog(SpookyReact(bot)) diff --git a/bot/exts/holidays/halloween/8ball.py b/bot/exts/holidays/halloween/8ball.py new file mode 100644 index 00000000..4fec8463 --- /dev/null +++ b/bot/exts/holidays/halloween/8ball.py @@ -0,0 +1,31 @@ +import asyncio +import json +import logging +import random +from pathlib import Path + +from discord.ext import commands + +from bot.bot import Bot + +log = logging.getLogger(__name__) + +RESPONSES = json.loads(Path("bot/resources/holidays/halloween/responses.json").read_text("utf8")) + + +class SpookyEightBall(commands.Cog): + """Spooky Eightball answers.""" + + @commands.command(aliases=("spooky8ball",)) + async def spookyeightball(self, ctx: commands.Context, *, question: str) -> None: + """Responds with a random response to a question.""" + choice = random.choice(RESPONSES["responses"]) + msg = await ctx.send(choice[0]) + if len(choice) > 1: + await asyncio.sleep(random.randint(2, 5)) + await msg.edit(content=f"{choice[0]} \n{choice[1]}") + + +def setup(bot: Bot) -> None: + """Load the Spooky Eight Ball Cog.""" + bot.add_cog(SpookyEightBall()) diff --git a/bot/exts/holidays/halloween/__init__.py b/bot/exts/holidays/halloween/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bot/exts/holidays/halloween/candy_collection.py b/bot/exts/holidays/halloween/candy_collection.py new file mode 100644 index 00000000..4afd5913 --- /dev/null +++ b/bot/exts/holidays/halloween/candy_collection.py @@ -0,0 +1,203 @@ +import logging +import random +from typing import Union + +import discord +from async_rediscache import RedisCache +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Channels, Month +from bot.utils.decorators import in_month + +log = logging.getLogger(__name__) + +# chance is 1 in x range, so 1 in 20 range would give 5% chance (for add candy) +ADD_CANDY_REACTION_CHANCE = 20 # 5% +ADD_CANDY_EXISTING_REACTION_CHANCE = 10 # 10% +ADD_SKULL_REACTION_CHANCE = 50 # 2% +ADD_SKULL_EXISTING_REACTION_CHANCE = 20 # 5% + +EMOJIS = dict( + CANDY="\N{CANDY}", + SKULL="\N{SKULL}", + MEDALS=( + "\N{FIRST PLACE MEDAL}", + "\N{SECOND PLACE MEDAL}", + "\N{THIRD PLACE MEDAL}", + "\N{SPORTS MEDAL}", + "\N{SPORTS MEDAL}", + ), +) + + +class CandyCollection(commands.Cog): + """Candy collection game Cog.""" + + # User candy amount records + candy_records = RedisCache() + + # Candy and skull messages mapping + candy_messages = RedisCache() + skull_messages = RedisCache() + + def __init__(self, bot: Bot): + self.bot = bot + + @in_month(Month.OCTOBER) + @commands.Cog.listener() + async def on_message(self, message: discord.Message) -> None: + """Randomly adds candy or skull reaction to non-bot messages in the Event channel.""" + # Ignore messages in DMs + if not message.guild: + return + # make sure its a human message + if message.author.bot: + return + # ensure it's hacktober channel + if message.channel.id != Channels.community_bot_commands: + return + + # do random check for skull first as it has the lower chance + if random.randint(1, ADD_SKULL_REACTION_CHANCE) == 1: + await self.skull_messages.set(message.id, "skull") + await message.add_reaction(EMOJIS["SKULL"]) + # check for the candy chance next + elif random.randint(1, ADD_CANDY_REACTION_CHANCE) == 1: + await self.candy_messages.set(message.id, "candy") + await message.add_reaction(EMOJIS["CANDY"]) + + @in_month(Month.OCTOBER) + @commands.Cog.listener() + async def on_reaction_add(self, reaction: discord.Reaction, user: Union[discord.User, discord.Member]) -> None: + """Add/remove candies from a person if the reaction satisfies criteria.""" + message = reaction.message + # check to ensure the reactor is human + if user.bot: + return + + # check to ensure it is in correct channel + if message.channel.id != Channels.community_bot_commands: + return + + # if its not a candy or skull, and it is one of 10 most recent messages, + # proceed to add a skull/candy with higher chance + if str(reaction.emoji) not in (EMOJIS["SKULL"], EMOJIS["CANDY"]): + recent_message_ids = map( + lambda m: m.id, + await self.hacktober_channel.history(limit=10).flatten() + ) + if message.id in recent_message_ids: + await self.reacted_msg_chance(message) + return + + if await self.candy_messages.get(message.id) == "candy" and str(reaction.emoji) == EMOJIS["CANDY"]: + await self.candy_messages.delete(message.id) + if await self.candy_records.contains(user.id): + await self.candy_records.increment(user.id) + else: + await self.candy_records.set(user.id, 1) + + elif await self.skull_messages.get(message.id) == "skull" and str(reaction.emoji) == EMOJIS["SKULL"]: + await self.skull_messages.delete(message.id) + + if prev_record := await self.candy_records.get(user.id): + lost = min(random.randint(1, 3), prev_record) + await self.candy_records.decrement(user.id, lost) + + if lost == prev_record: + await CandyCollection.send_spook_msg(user, message.channel, "all of your") + else: + await CandyCollection.send_spook_msg(user, message.channel, lost) + else: + await CandyCollection.send_no_candy_spook_message(user, message.channel) + else: + return # Skip saving + + await reaction.clear() + + async def reacted_msg_chance(self, message: discord.Message) -> None: + """ + Randomly add a skull or candy reaction to a message if there is a reaction there already. + + This event has a higher probability of occurring than a reaction add to a message without an + existing reaction. + """ + if random.randint(1, ADD_SKULL_EXISTING_REACTION_CHANCE) == 1: + await self.skull_messages.set(message.id, "skull") + await message.add_reaction(EMOJIS["SKULL"]) + + elif random.randint(1, ADD_CANDY_EXISTING_REACTION_CHANCE) == 1: + await self.candy_messages.set(message.id, "candy") + await message.add_reaction(EMOJIS["CANDY"]) + + @property + def hacktober_channel(self) -> discord.TextChannel: + """Get #hacktoberbot channel from its ID.""" + return self.bot.get_channel(id=Channels.community_bot_commands) + + @staticmethod + async def send_spook_msg( + author: discord.Member, channel: discord.TextChannel, candies: Union[str, int] + ) -> None: + """Send a spooky message.""" + e = discord.Embed(colour=author.colour) + e.set_author( + name="Ghosts and Ghouls and Jack o' lanterns at night; " + f"I took {candies} candies and quickly took flight." + ) + await channel.send(embed=e) + + @staticmethod + async def send_no_candy_spook_message( + author: discord.Member, + channel: discord.TextChannel + ) -> None: + """An alternative spooky message sent when user has no candies in the collection.""" + embed = discord.Embed(color=author.color) + embed.set_author( + name=( + "Ghosts and Ghouls and Jack o' lanterns at night; " + "I tried to take your candies but you had none to begin with!" + ) + ) + await channel.send(embed=embed) + + @in_month(Month.OCTOBER) + @commands.command() + async def candy(self, ctx: commands.Context) -> None: + """Get the candy leaderboard and save to JSON.""" + records = await self.candy_records.items() + + def generate_leaderboard() -> str: + top_sorted = sorted( + ((user_id, score) for user_id, score in records if score > 0), + key=lambda x: x[1], + reverse=True + ) + top_five = top_sorted[:5] + + return "\n".join( + f"{EMOJIS['MEDALS'][index]} <@{record[0]}>: {record[1]}" + for index, record in enumerate(top_five) + ) if top_five else "No Candies" + + e = discord.Embed(colour=discord.Colour.blurple()) + e.add_field( + name="Top Candy Records", + value=generate_leaderboard(), + inline=False + ) + e.add_field( + name="\u200b", + value="Candies will randomly appear on messages sent. " + "\nHit the candy when it appears as fast as possible to get the candy! " + "\nBut beware the ghosts...", + inline=False + ) + await ctx.send(embed=e) + + +def setup(bot: Bot) -> None: + """Load the Candy Collection Cog.""" + bot.add_cog(CandyCollection(bot)) diff --git a/bot/exts/holidays/halloween/halloween_facts.py b/bot/exts/holidays/halloween/halloween_facts.py new file mode 100644 index 00000000..adde2310 --- /dev/null +++ b/bot/exts/holidays/halloween/halloween_facts.py @@ -0,0 +1,55 @@ +import json +import logging +import random +from datetime import timedelta +from pathlib import Path + +import discord +from discord.ext import commands + +from bot.bot import Bot + +log = logging.getLogger(__name__) + +SPOOKY_EMOJIS = [ + "\N{BAT}", + "\N{DERELICT HOUSE BUILDING}", + "\N{EXTRATERRESTRIAL ALIEN}", + "\N{GHOST}", + "\N{JACK-O-LANTERN}", + "\N{SKULL}", + "\N{SKULL AND CROSSBONES}", + "\N{SPIDER WEB}", +] +PUMPKIN_ORANGE = 0xFF7518 +INTERVAL = timedelta(hours=6).total_seconds() + +FACTS = json.loads(Path("bot/resources/holidays/halloween/halloween_facts.json").read_text("utf8")) +FACTS = list(enumerate(FACTS)) + + +class HalloweenFacts(commands.Cog): + """A Cog for displaying interesting facts about Halloween.""" + + def random_fact(self) -> tuple[int, str]: + """Return a random fact from the loaded facts.""" + return random.choice(FACTS) + + @commands.command(name="spookyfact", aliases=("halloweenfact",), brief="Get the most recent Halloween fact") + async def get_random_fact(self, ctx: commands.Context) -> None: + """Reply with the most recent Halloween fact.""" + index, fact = self.random_fact() + embed = self._build_embed(index, fact) + await ctx.send(embed=embed) + + @staticmethod + def _build_embed(index: int, fact: str) -> discord.Embed: + """Builds a Discord embed from the given fact and its index.""" + emoji = random.choice(SPOOKY_EMOJIS) + title = f"{emoji} Halloween Fact #{index + 1}" + return discord.Embed(title=title, description=fact, color=PUMPKIN_ORANGE) + + +def setup(bot: Bot) -> None: + """Load the Halloween Facts Cog.""" + bot.add_cog(HalloweenFacts()) diff --git a/bot/exts/holidays/halloween/halloweenify.py b/bot/exts/holidays/halloween/halloweenify.py new file mode 100644 index 00000000..03b52589 --- /dev/null +++ b/bot/exts/holidays/halloween/halloweenify.py @@ -0,0 +1,64 @@ +import logging +from json import loads +from pathlib import Path +from random import choice + +import discord +from discord.errors import Forbidden +from discord.ext import commands +from discord.ext.commands import BucketType + +from bot.bot import Bot + +log = logging.getLogger(__name__) + +HALLOWEENIFY_DATA = loads(Path("bot/resources/holidays/halloween/halloweenify.json").read_text("utf8")) + + +class Halloweenify(commands.Cog): + """A cog to change a invokers nickname to a spooky one!""" + + @commands.cooldown(1, 300, BucketType.user) + @commands.command() + async def halloweenify(self, ctx: commands.Context) -> None: + """Change your nickname into a much spookier one!""" + async with ctx.typing(): + # Choose a random character from our list we loaded above and set apart the nickname and image url. + character = choice(HALLOWEENIFY_DATA["characters"]) + nickname = "".join(nickname for nickname in character) + image = "".join(character[nickname] for nickname in character) + + # Build up a Embed + embed = discord.Embed() + embed.colour = discord.Colour.dark_orange() + embed.title = "Not spooky enough?" + embed.description = ( + f"**{ctx.author.display_name}** wasn't spooky enough for you? That's understandable, " + f"{ctx.author.display_name} isn't scary at all! " + "Let me think of something better. Hmm... I got it!\n\n " + ) + embed.set_image(url=image) + + if isinstance(ctx.author, discord.Member): + try: + await ctx.author.edit(nick=nickname) + embed.description += f"Your new nickname will be: \n:ghost: **{nickname}** :jack_o_lantern:" + + except Forbidden: # The bot doesn't have enough permission + embed.description += ( + f"Your new nickname should be: \n :ghost: **{nickname}** :jack_o_lantern: \n\n" + f"It looks like I cannot change your name, but feel free to change it yourself." + ) + + else: # The command has been invoked in DM + embed.description += ( + f"Your new nickname should be: \n :ghost: **{nickname}** :jack_o_lantern: \n\n" + f"Feel free to change it yourself, or invoke the command again inside the server." + ) + + await ctx.send(embed=embed) + + +def setup(bot: Bot) -> None: + """Load the Halloweenify Cog.""" + bot.add_cog(Halloweenify()) diff --git a/bot/exts/holidays/halloween/monsterbio.py b/bot/exts/holidays/halloween/monsterbio.py new file mode 100644 index 00000000..0556a193 --- /dev/null +++ b/bot/exts/holidays/halloween/monsterbio.py @@ -0,0 +1,54 @@ +import json +import logging +import random +from pathlib import Path + +import discord +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Colours + +log = logging.getLogger(__name__) + +TEXT_OPTIONS = json.loads( + Path("bot/resources/holidays/halloween/monster.json").read_text("utf8") +) # Data for a mad-lib style generation of text + + +class MonsterBio(commands.Cog): + """A cog that generates a spooky monster biography.""" + + def generate_name(self, seeded_random: random.Random) -> str: + """Generates a name (for either monster species or monster name).""" + n_candidate_strings = seeded_random.randint(2, len(TEXT_OPTIONS["monster_type"])) + return "".join(seeded_random.choice(TEXT_OPTIONS["monster_type"][i]) for i in range(n_candidate_strings)) + + @commands.command(brief="Sends your monster bio!") + async def monsterbio(self, ctx: commands.Context) -> None: + """Sends a description of a monster.""" + seeded_random = random.Random(ctx.author.id) # Seed a local Random instance rather than the system one + + name = self.generate_name(seeded_random) + species = self.generate_name(seeded_random) + biography_text = seeded_random.choice(TEXT_OPTIONS["biography_text"]) + words = {"monster_name": name, "monster_species": species} + for key, value in biography_text.items(): + if key == "text": + continue + + options = seeded_random.sample(TEXT_OPTIONS[key], value) + words[key] = " ".join(options) + + embed = discord.Embed( + title=f"{name}'s Biography", + color=seeded_random.choice([Colours.orange, Colours.purple]), + description=biography_text["text"].format_map(words), + ) + + await ctx.send(embed=embed) + + +def setup(bot: Bot) -> None: + """Load the Monster Bio Cog.""" + bot.add_cog(MonsterBio()) diff --git a/bot/exts/holidays/halloween/monstersurvey.py b/bot/exts/holidays/halloween/monstersurvey.py new file mode 100644 index 00000000..f3433886 --- /dev/null +++ b/bot/exts/holidays/halloween/monstersurvey.py @@ -0,0 +1,205 @@ +import json +import logging +import pathlib + +from discord import Embed +from discord.ext import commands +from discord.ext.commands import Bot, Cog, Context + +log = logging.getLogger(__name__) + +EMOJIS = { + "SUCCESS": u"\u2705", + "ERROR": u"\u274C" +} + + +class MonsterSurvey(Cog): + """ + Vote for your favorite monster. + + This Cog allows users to vote for their favorite listed monster. + + Users may change their vote, but only their current vote will be counted. + """ + + def __init__(self): + """Initializes values for the bot to use within the voting commands.""" + self.registry_path = pathlib.Path("bot", "resources", "holidays", "halloween", "monstersurvey.json") + self.voter_registry = json.loads(self.registry_path.read_text("utf8")) + + def json_write(self) -> None: + """Write voting results to a local JSON file.""" + log.info("Saved Monster Survey Results") + self.registry_path.write_text(json.dumps(self.voter_registry, indent=2)) + + def cast_vote(self, id: int, monster: str) -> None: + """ + Cast a user's vote for the specified monster. + + If the user has already voted, their existing vote is removed. + """ + vr = self.voter_registry + for m in vr: + if id not in vr[m]["votes"] and m == monster: + vr[m]["votes"].append(id) + else: + if id in vr[m]["votes"] and m != monster: + vr[m]["votes"].remove(id) + + def get_name_by_leaderboard_index(self, n: int) -> str: + """Return the monster at the specified leaderboard index.""" + n = n - 1 + vr = self.voter_registry + top = sorted(vr, key=lambda k: len(vr[k]["votes"]), reverse=True) + name = top[n] if n >= 0 else None + return name + + @commands.group( + name="monster", + aliases=("mon",) + ) + async def monster_group(self, ctx: Context) -> None: + """The base voting command. If nothing is called, then it will return an embed.""" + if ctx.invoked_subcommand is None: + async with ctx.typing(): + default_embed = Embed( + title="Monster Voting", + color=0xFF6800, + description="Vote for your favorite monster!" + ) + default_embed.add_field( + name=".monster show monster_name(optional)", + value="Show a specific monster. If none is listed, it will give you an error with valid choices.", + inline=False + ) + default_embed.add_field( + name=".monster vote monster_name", + value="Vote for a specific monster. You get one vote, but can change it at any time.", + inline=False + ) + default_embed.add_field( + name=".monster leaderboard", + value="Which monster has the most votes? This command will tell you.", + inline=False + ) + default_embed.set_footer(text=f"Monsters choices are: {', '.join(self.voter_registry)}") + + await ctx.send(embed=default_embed) + + @monster_group.command( + name="vote" + ) + async def monster_vote(self, ctx: Context, name: str = None) -> None: + """ + Cast a vote for a particular monster. + + Displays a list of monsters that can be voted for if one is not specified. + """ + if name is None: + await ctx.invoke(self.monster_leaderboard) + return + + async with ctx.typing(): + # Check to see if user used a numeric (leaderboard) index to vote + try: + idx = int(name) + name = self.get_name_by_leaderboard_index(idx) + except ValueError: + name = name.lower() + + vote_embed = Embed( + name="Monster Voting", + color=0xFF6800 + ) + + m = self.voter_registry.get(name) + if m is None: + vote_embed.description = f"You cannot vote for {name} because it's not in the running." + vote_embed.add_field( + name="Use `.monster show {monster_name}` for more information on a specific monster", + value="or use `.monster vote {monster}` to cast your vote for said monster.", + inline=False + ) + vote_embed.add_field( + name="You may vote for or show the following monsters:", + value=", ".join(self.voter_registry.keys()) + ) + else: + self.cast_vote(ctx.author.id, name) + vote_embed.add_field( + name="Vote successful!", + value=f"You have successfully voted for {m['full_name']}!", + inline=False + ) + vote_embed.set_thumbnail(url=m["image"]) + vote_embed.set_footer(text="Please note that any previous votes have been removed.") + self.json_write() + + await ctx.send(embed=vote_embed) + + @monster_group.command( + name="show" + ) + async def monster_show(self, ctx: Context, name: str = None) -> None: + """Shows the named monster. If one is not named, it sends the default voting embed instead.""" + if name is None: + await ctx.invoke(self.monster_leaderboard) + return + + async with ctx.typing(): + # Check to see if user used a numeric (leaderboard) index to vote + try: + idx = int(name) + name = self.get_name_by_leaderboard_index(idx) + except ValueError: + name = name.lower() + + m = self.voter_registry.get(name) + if not m: + await ctx.send("That monster does not exist.") + await ctx.invoke(self.monster_vote) + return + + embed = Embed(title=m["full_name"], color=0xFF6800) + embed.add_field(name="Summary", value=m["summary"]) + embed.set_image(url=m["image"]) + embed.set_footer(text=f"To vote for this monster, type .monster vote {name}") + + await ctx.send(embed=embed) + + @monster_group.command( + name="leaderboard", + aliases=("lb",) + ) + async def monster_leaderboard(self, ctx: Context) -> None: + """Shows the current standings.""" + async with ctx.typing(): + vr = self.voter_registry + top = sorted(vr, key=lambda k: len(vr[k]["votes"]), reverse=True) + total_votes = sum(len(m["votes"]) for m in self.voter_registry.values()) + + embed = Embed(title="Monster Survey Leader Board", color=0xFF6800) + for rank, m in enumerate(top): + votes = len(vr[m]["votes"]) + percentage = ((votes / total_votes) * 100) if total_votes > 0 else 0 + embed.add_field( + name=f"{rank+1}. {vr[m]['full_name']}", + value=( + f"{votes} votes. {percentage:.1f}% of total votes.\n" + f"Vote for this monster by typing " + f"'.monster vote {m}'\n" + f"Get more information on this monster by typing " + f"'.monster show {m}'" + ), + inline=False + ) + + embed.set_footer(text="You can also vote by their rank number. '.monster vote {number}' ") + + await ctx.send(embed=embed) + + +def setup(bot: Bot) -> None: + """Load the Monster Survey Cog.""" + bot.add_cog(MonsterSurvey()) diff --git a/bot/exts/holidays/halloween/scarymovie.py b/bot/exts/holidays/halloween/scarymovie.py new file mode 100644 index 00000000..33659fd8 --- /dev/null +++ b/bot/exts/holidays/halloween/scarymovie.py @@ -0,0 +1,124 @@ +import logging +import random + +from discord import Embed +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Tokens +log = logging.getLogger(__name__) + + +class ScaryMovie(commands.Cog): + """Selects a random scary movie and embeds info into Discord chat.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @commands.command(name="scarymovie", alias=["smovie"]) + async def random_movie(self, ctx: commands.Context) -> None: + """Randomly select a scary movie and display information about it.""" + async with ctx.typing(): + selection = await self.select_movie() + movie_details = await self.format_metadata(selection) + + await ctx.send(embed=movie_details) + + async def select_movie(self) -> dict: + """Selects a random movie and returns a JSON of movie details from TMDb.""" + url = "https://api.themoviedb.org/3/discover/movie" + params = { + "api_key": Tokens.tmdb, + "with_genres": "27", + "vote_count.gte": "5", + "include_adult": "false" + } + headers = { + "Content-Type": "application/json;charset=utf-8" + } + + # Get total page count of horror movies + async with self.bot.http_session.get(url=url, params=params, headers=headers) as response: + data = await response.json() + total_pages = data.get("total_pages") + + # Get movie details from one random result on a random page + params["page"] = random.randint(1, total_pages) + async with self.bot.http_session.get(url=url, params=params, headers=headers) as response: + data = await response.json() + selection_id = random.choice(data.get("results")).get("id") + + # Get full details and credits + async with self.bot.http_session.get( + url=f"https://api.themoviedb.org/3/movie/{selection_id}", + params={"api_key": Tokens.tmdb, "append_to_response": "credits"} + ) as selection: + + return await selection.json() + + @staticmethod + async def format_metadata(movie: dict) -> Embed: + """Formats raw TMDb data to be embedded in Discord chat.""" + # Build the relevant URLs. + movie_id = movie.get("id") + poster_path = movie.get("poster_path") + tmdb_url = f"https://www.themoviedb.org/movie/{movie_id}" if movie_id else None + poster = f"https://image.tmdb.org/t/p/original{poster_path}" if poster_path else None + + # Get cast names + cast = [] + for actor in movie.get("credits", {}).get("cast", [])[:3]: + cast.append(actor.get("name")) + + # Get director name + director = movie.get("credits", {}).get("crew", []) + if director: + director = director[0].get("name") + + # Determine the spookiness rating + rating = "" + rating_count = movie.get("vote_average", 0) / 2 + + for _ in range(int(rating_count)): + rating += ":skull:" + if (rating_count % 1) >= .5: + rating += ":bat:" + + # Try to get year of release and runtime + year = movie.get("release_date", [])[:4] + runtime = movie.get("runtime") + runtime = f"{runtime} minutes" if runtime else None + + # Not all these attributes will always be present + movie_attributes = { + "Directed by": director, + "Starring": ", ".join(cast), + "Running time": runtime, + "Release year": year, + "Spookiness rating": rating, + } + + embed = Embed( + colour=0x01d277, + title=f"**{movie.get('title')}**", + url=tmdb_url, + description=movie.get("overview") + ) + + if poster: + embed.set_image(url=poster) + + # Add the attributes that we actually have data for, but not the others. + for name, value in movie_attributes.items(): + if value: + embed.add_field(name=name, value=value) + + embed.set_footer(text="This product uses the TMDb API but is not endorsed or certified by TMDb.") + embed.set_thumbnail(url="https://i.imgur.com/LtFtC8H.png") + + return embed + + +def setup(bot: Bot) -> None: + """Load the Scary Movie Cog.""" + bot.add_cog(ScaryMovie(bot)) diff --git a/bot/exts/holidays/halloween/spookygif.py b/bot/exts/holidays/halloween/spookygif.py new file mode 100644 index 00000000..9511d407 --- /dev/null +++ b/bot/exts/holidays/halloween/spookygif.py @@ -0,0 +1,38 @@ +import logging + +import discord +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Colours, Tokens + +log = logging.getLogger(__name__) + +API_URL = "http://api.giphy.com/v1/gifs/random" + + +class SpookyGif(commands.Cog): + """A cog to fetch a random spooky gif from the web!""" + + def __init__(self, bot: Bot): + self.bot = bot + + @commands.command(name="spookygif", aliases=("sgif", "scarygif")) + async def spookygif(self, ctx: commands.Context) -> None: + """Fetches a random gif from the GIPHY API and responds with it.""" + async with ctx.typing(): + params = {"api_key": Tokens.giphy, "tag": "halloween", "rating": "g"} + # Make a GET request to the Giphy API to get a random halloween gif. + async with self.bot.http_session.get(API_URL, params=params) as resp: + data = await resp.json() + url = data["data"]["image_url"] + + embed = discord.Embed(title="A spooooky gif!", colour=Colours.purple) + embed.set_image(url=url) + + await ctx.send(embed=embed) + + +def setup(bot: Bot) -> None: + """Spooky GIF Cog load.""" + bot.add_cog(SpookyGif(bot)) diff --git a/bot/exts/holidays/halloween/spookynamerate.py b/bot/exts/holidays/halloween/spookynamerate.py new file mode 100644 index 00000000..2e59d4a8 --- /dev/null +++ b/bot/exts/holidays/halloween/spookynamerate.py @@ -0,0 +1,391 @@ +import asyncio +import json +import random +from collections import defaultdict +from datetime import datetime, timedelta +from logging import getLogger +from os import getenv +from pathlib import Path +from typing import Optional + +from async_rediscache import RedisCache +from discord import Embed, Reaction, TextChannel, User +from discord.colour import Colour +from discord.ext import tasks +from discord.ext.commands import Cog, Context, group + +from bot.bot import Bot +from bot.constants import Channels, Client, Colours, Month +from bot.utils.decorators import InMonthCheckFailure + +logger = getLogger(__name__) + +EMOJIS_VAL = { + "\N{Jack-O-Lantern}": 1, + "\N{Ghost}": 2, + "\N{Skull and Crossbones}": 3, + "\N{Zombie}": 4, + "\N{Face Screaming In Fear}": 5, +} +ADDED_MESSAGES = [ + "Let's see if you win?", + ":jack_o_lantern: SPOOKY :jack_o_lantern:", + "If you got it, haunt it.", + "TIME TO GET YOUR SPOOKY ON! :skull:", +] +PING = "<@{id}>" + +EMOJI_MESSAGE = "\n".join(f"- {emoji} {val}" for emoji, val in EMOJIS_VAL.items()) +HELP_MESSAGE_DICT = { + "title": "Spooky Name Rate", + "description": f"Help for the `{Client.prefix}spookynamerate` command", + "color": Colours.soft_orange, + "fields": [ + { + "name": "How to play", + "value": ( + "Everyday, the bot will post a random name, which you will need to spookify using your creativity.\n" + "You can rate each message according to how scary it is.\n" + "At the end of the day, the author of the message with most reactions will be the winner of the day.\n" + f"On a scale of 1 to {len(EMOJIS_VAL)}, the reactions order:\n" + f"{EMOJI_MESSAGE}" + ), + "inline": False, + }, + { + "name": "How do I add my spookified name?", + "value": f"Simply type `{Client.prefix}spookynamerate add my name`", + "inline": False, + }, + { + "name": "How do I *delete* my spookified name?", + "value": f"Simply type `{Client.prefix}spookynamerate delete`", + "inline": False, + }, + ], +} + +# The names are from https://www.mockaroo.com/ +NAMES = json.loads(Path("bot/resources/holidays/halloween/spookynamerate_names.json").read_text("utf8")) +FIRST_NAMES = NAMES["first_names"] +LAST_NAMES = NAMES["last_names"] + + +class SpookyNameRate(Cog): + """ + A game that asks the user to spookify or halloweenify a name that is given everyday. + + It sends a random name everyday. The user needs to try and spookify it to his best ability and + send that name back using the `spookynamerate add entry` command + """ + + # This cache stores the message id of each added word along with a dictionary which contains the name the author + # added, the author's id, and the author's score (which is 0 by default) + messages = RedisCache() + + # The data cache stores small information such as the current name that is going on and whether it is the first time + # the bot is running + data = RedisCache() + debug = getenv("SPOOKYNAMERATE_DEBUG", False) # Enable if you do not want to limit the commands to October or if + # you do not want to wait till 12 UTC. Note: if debug is enabled and you run `.cogs reload spookynamerate`, it + # will automatically start the scoring and announcing the result (without waiting for 12, so do not expect it to.). + # Also, it won't wait for the two hours (when the poll closes). + + def __init__(self, bot: Bot): + self.bot = bot + self.name = None + + self.bot.loop.create_task(self.load_vars()) + + self.first_time = None + self.poll = False + self.announce_name.start() + self.checking_messages = asyncio.Lock() + # Define an asyncio.Lock() to make sure the dictionary isn't changed + # when checking the messages for duplicate emojis' + + async def load_vars(self) -> None: + """Loads the variables that couldn't be loaded in __init__.""" + self.first_time = await self.data.get("first_time", True) + self.name = await self.data.get("name") + + @group(name="spookynamerate", invoke_without_command=True) + async def spooky_name_rate(self, ctx: Context) -> None: + """Get help on the Spooky Name Rate game.""" + await ctx.send(embed=Embed.from_dict(HELP_MESSAGE_DICT)) + + @spooky_name_rate.command(name="list", aliases=("all", "entries")) + async def list_entries(self, ctx: Context) -> None: + """Send all the entries up till now in a single embed.""" + await ctx.send(embed=await self.get_responses_list(final=False)) + + @spooky_name_rate.command(name="name") + async def tell_name(self, ctx: Context) -> None: + """Tell the current random name.""" + if not self.poll: + await ctx.send(f"The name is **{self.name}**") + return + + await ctx.send( + f"The name ~~is~~ was **{self.name}**. The poll has already started, so you cannot " + "add an entry." + ) + + @spooky_name_rate.command(name="add", aliases=("register",)) + async def add_name(self, ctx: Context, *, name: str) -> None: + """Use this command to add/register your spookified name.""" + if self.poll: + logger.info(f"{ctx.author} tried to add a name, but the poll had already started.") + await ctx.send("Sorry, the poll has started! You can try and participate in the next round though!") + return + + for data in (json.loads(user_data) for _, user_data in await self.messages.items()): + if data["author"] == ctx.author.id: + await ctx.send( + "But you have already added an entry! Type " + f"`{self.bot.command_prefix}spookynamerate " + "delete` to delete it, and then you can add it again" + ) + return + + elif data["name"] == name: + await ctx.send("TOO LATE. Someone has already added this name.") + return + + msg = await (await self.get_channel()).send(f"{ctx.author.mention} added the name {name!r}!") + + await self.messages.set( + msg.id, + json.dumps( + { + "name": name, + "author": ctx.author.id, + "score": 0, + } + ), + ) + + for emoji in EMOJIS_VAL: + await msg.add_reaction(emoji) + + logger.info(f"{ctx.author} added the name {name!r}") + + @spooky_name_rate.command(name="delete") + async def delete_name(self, ctx: Context) -> None: + """Delete the user's name.""" + if self.poll: + await ctx.send("You can't delete your name since the poll has already started!") + return + for message_id, data in await self.messages.items(): + data = json.loads(data) + + if ctx.author.id == data["author"]: + await self.messages.delete(message_id) + await ctx.send(f"Name deleted successfully ({data['name']!r})!") + return + + await ctx.send( + f"But you don't have an entry... :eyes: Type `{self.bot.command_prefix}spookynamerate add your entry`" + ) + + @Cog.listener() + async def on_reaction_add(self, reaction: Reaction, user: User) -> None: + """Ensures that each user adds maximum one reaction.""" + if user.bot or not await self.messages.contains(reaction.message.id): + return + + async with self.checking_messages: # Acquire the lock so that the dictionary isn't reset while iterating. + if reaction.emoji in EMOJIS_VAL: + # create a custom counter + reaction_counter = defaultdict(int) + for msg_reaction in reaction.message.reactions: + async for reaction_user in msg_reaction.users(): + if reaction_user == self.bot.user: + continue + reaction_counter[reaction_user] += 1 + + if reaction_counter[user] > 1: + await user.send( + "Sorry, you have already added a reaction, " + "please remove your reaction and try again." + ) + await reaction.remove(user) + return + + @tasks.loop(hours=24.0) + async def announce_name(self) -> None: + """Announces the name needed to spookify every 24 hours and the winner of the previous game.""" + if not self.in_allowed_month(): + return + + channel = await self.get_channel() + + if self.first_time: + await channel.send( + "Okkey... Welcome to the **Spooky Name Rate Game**! It's a relatively simple game.\n" + f"Everyday, a random name will be sent in <#{Channels.community_bot_commands}> " + "and you need to try and spookify it!\nRegister your name using " + f"`{self.bot.command_prefix}spookynamerate add spookified name`" + ) + + await self.data.set("first_time", False) + self.first_time = False + + else: + if await self.messages.items(): + await channel.send(embed=await self.get_responses_list(final=True)) + self.poll = True + if not SpookyNameRate.debug: + await asyncio.sleep(2 * 60 * 60) # sleep for two hours + + logger.info("Calculating score") + for message_id, data in await self.messages.items(): + data = json.loads(data) + + msg = await channel.fetch_message(message_id) + score = 0 + for reaction in msg.reactions: + reaction_value = EMOJIS_VAL.get(reaction.emoji, 0) # get the value of the emoji else 0 + score += reaction_value * (reaction.count - 1) # multiply by the num of reactions + # subtract one, since one reaction was done by the bot + + logger.debug(f"{self.bot.get_user(data['author'])} got a score of {score}") + data["score"] = score + await self.messages.set(message_id, json.dumps(data)) + + # Sort the winner messages + winner_messages = sorted( + ((msg_id, json.loads(usr_data)) for msg_id, usr_data in await self.messages.items()), + key=lambda x: x[1]["score"], + reverse=True, + ) + + winners = [] + for i, winner in enumerate(winner_messages): + winners.append(winner) + if len(winner_messages) > i + 1: + if winner_messages[i + 1][1]["score"] != winner[1]["score"]: + break + elif len(winner_messages) == (i + 1) + 1: # The next element is the last element + if winner_messages[i + 1][1]["score"] != winner[1]["score"]: + break + + # one iteration is complete + await channel.send("Today's Spooky Name Rate Game ends now, and the winner(s) is(are)...") + + async with channel.typing(): + await asyncio.sleep(1) # give the drum roll feel + + if not winners: # There are no winners (no participants) + await channel.send("Hmm... Looks like no one participated! :cry:") + return + + score = winners[0][1]["score"] + congratulations = "to all" if len(winners) > 1 else PING.format(id=winners[0][1]["author"]) + names = ", ".join(f'{win[1]["name"]} ({PING.format(id=win[1]["author"])})' for win in winners) + + # display winners, their names and scores + await channel.send( + f"Congratulations {congratulations}!\n" + f"You have a score of {score}!\n" + f"Your name{ 's were' if len(winners) > 1 else 'was'}:\n{names}" + ) + + # Send random party emojis + party = (random.choice([":partying_face:", ":tada:"]) for _ in range(random.randint(1, 10))) + await channel.send(" ".join(party)) + + async with self.checking_messages: # Acquire the lock to delete the messages + await self.messages.clear() # reset the messages + + # send the next name + self.name = f"{random.choice(FIRST_NAMES)} {random.choice(LAST_NAMES)}" + await self.data.set("name", self.name) + + await channel.send( + "Let's move on to the next name!\nAnd the next name is...\n" + f"**{self.name}**!\nTry to spookify that... :smirk:" + ) + + self.poll = False # accepting responses + + @announce_name.before_loop + async def wait_till_scheduled_time(self) -> None: + """Waits till the next day's 12PM if crossed it, otherwise waits till the same day's 12PM.""" + if SpookyNameRate.debug: + return + + now = datetime.utcnow() + if now.hour < 12: + twelve_pm = now.replace(hour=12, minute=0, second=0, microsecond=0) + time_left = twelve_pm - now + await asyncio.sleep(time_left.seconds) + return + + tomorrow_12pm = now + timedelta(days=1) + tomorrow_12pm = tomorrow_12pm.replace(hour=12, minute=0, second=0, microsecond=0) + await asyncio.sleep((tomorrow_12pm - now).seconds) + + async def get_responses_list(self, final: bool = False) -> Embed: + """Returns an embed containing the responses of the people.""" + channel = await self.get_channel() + + embed = Embed(color=Colour.red()) + + if await self.messages.items(): + if final: + embed.title = "Spooky Name Rate is about to end!" + embed.description = ( + "This Spooky Name Rate round is about to end in 2 hours! You can review " + "the entries below! Have you rated other's names?" + ) + else: + embed.title = "All the spookified names!" + embed.description = "See a list of all the entries entered by everyone!" + else: + embed.title = "No one has added an entry yet..." + + for message_id, data in await self.messages.items(): + data = json.loads(data) + + embed.add_field( + name=(self.bot.get_user(data["author"]) or await self.bot.fetch_user(data["author"])).name, + value=f"[{(data)['name']}](https://discord.com/channels/{Client.guild}/{channel.id}/{message_id})", + ) + + return embed + + async def get_channel(self) -> Optional[TextChannel]: + """Gets the sir-lancebot-channel after waiting until ready.""" + await self.bot.wait_until_ready() + channel = self.bot.get_channel( + Channels.community_bot_commands + ) or await self.bot.fetch_channel(Channels.community_bot_commands) + if not channel: + logger.warning("Bot is unable to get the #seasonalbot-commands channel. Please check the channel ID.") + return channel + + @staticmethod + def in_allowed_month() -> bool: + """Returns whether running in the limited month.""" + if SpookyNameRate.debug: + return True + + if not Client.month_override: + return datetime.utcnow().month == Month.OCTOBER + return Client.month_override == Month.OCTOBER + + def cog_check(self, ctx: Context) -> bool: + """A command to check whether the command is being called in October.""" + if not self.in_allowed_month(): + raise InMonthCheckFailure("You can only use these commands in October!") + return True + + def cog_unload(self) -> None: + """Stops the announce_name task.""" + self.announce_name.cancel() + + +def setup(bot: Bot) -> None: + """Load the SpookyNameRate Cog.""" + bot.add_cog(SpookyNameRate(bot)) diff --git a/bot/exts/holidays/halloween/spookyrating.py b/bot/exts/holidays/halloween/spookyrating.py new file mode 100644 index 00000000..ec6e8821 --- /dev/null +++ b/bot/exts/holidays/halloween/spookyrating.py @@ -0,0 +1,67 @@ +import bisect +import json +import logging +import random +from pathlib import Path + +import discord +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Colours + +log = logging.getLogger(__name__) + +data: dict[str, dict[str, str]] = json.loads( + Path("bot/resources/holidays/halloween/spooky_rating.json").read_text("utf8") +) +SPOOKY_DATA = sorted((int(key), value) for key, value in data.items()) + + +class SpookyRating(commands.Cog): + """A cog for calculating one's spooky rating.""" + + def __init__(self): + self.local_random = random.Random() + + @commands.command() + @commands.cooldown(rate=1, per=5, type=commands.BucketType.user) + async def spookyrating(self, ctx: commands.Context, who: discord.Member = None) -> None: + """ + Calculates the spooky rating of someone. + + Any user will always yield the same result, no matter who calls the command + """ + if who is None: + who = ctx.author + + # This ensures that the same result over multiple runtimes + self.local_random.seed(who.id) + spooky_percent = self.local_random.randint(1, 101) + + # We need the -1 due to how bisect returns the point + # see the documentation for further detail + # https://docs.python.org/3/library/bisect.html#bisect.bisect + index = bisect.bisect(SPOOKY_DATA, (spooky_percent,)) - 1 + + _, data = SPOOKY_DATA[index] + + embed = discord.Embed( + title=data["title"], + description=f"{who} scored {spooky_percent}%!", + color=Colours.orange + ) + embed.add_field( + name="A whisper from Satan", + value=data["text"] + ) + embed.set_thumbnail( + url=data["image"] + ) + + await ctx.send(embed=embed) + + +def setup(bot: Bot) -> None: + """Load the Spooky Rating Cog.""" + bot.add_cog(SpookyRating()) diff --git a/bot/exts/holidays/halloween/spookyreact.py b/bot/exts/holidays/halloween/spookyreact.py new file mode 100644 index 00000000..25e783f4 --- /dev/null +++ b/bot/exts/holidays/halloween/spookyreact.py @@ -0,0 +1,70 @@ +import logging +import re + +import discord +from discord.ext.commands import Cog + +from bot.bot import Bot +from bot.constants import Month +from bot.utils.decorators import in_month + +log = logging.getLogger(__name__) + +SPOOKY_TRIGGERS = { + "spooky": (r"\bspo{2,}ky\b", "\U0001F47B"), + "skeleton": (r"\bskeleton\b", "\U0001F480"), + "doot": (r"\bdo{2,}t\b", "\U0001F480"), + "pumpkin": (r"\bpumpkin\b", "\U0001F383"), + "halloween": (r"\bhalloween\b", "\U0001F383"), + "jack-o-lantern": (r"\bjack-o-lantern\b", "\U0001F383"), + "danger": (r"\bdanger\b", "\U00002620") +} + + +class SpookyReact(Cog): + """A cog that makes the bot react to message triggers.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @in_month(Month.OCTOBER) + @Cog.listener() + async def on_message(self, message: discord.Message) -> None: + """Triggered when the bot sees a message in October.""" + for name, trigger in SPOOKY_TRIGGERS.items(): + trigger_test = re.search(trigger[0], message.content.lower()) + if trigger_test: + # Check message for bot replies and/or command invocations + # Short circuit if they're found, logging is handled in _short_circuit_check + if await self._short_circuit_check(message): + return + else: + await message.add_reaction(trigger[1]) + log.info(f"Added {name!r} reaction to message ID: {message.id}") + + async def _short_circuit_check(self, message: discord.Message) -> bool: + """ + Short-circuit helper check. + + Return True if: + * author is the bot + * prefix is not None + """ + # Check for self reaction + if message.author == self.bot.user: + log.debug(f"Ignoring reactions on self message. Message ID: {message.id}") + return True + + # Check for command invocation + # Because on_message doesn't give a full Context object, generate one first + ctx = await self.bot.get_context(message) + if ctx.prefix: + log.debug(f"Ignoring reactions on command invocation. Message ID: {message.id}") + return True + + return False + + +def setup(bot: Bot) -> None: + """Load the Spooky Reaction Cog.""" + bot.add_cog(SpookyReact(bot)) diff --git a/bot/resources/halloween/bat-clipart.png b/bot/resources/halloween/bat-clipart.png deleted file mode 100644 index 7df26ba9..00000000 Binary files a/bot/resources/halloween/bat-clipart.png and /dev/null differ diff --git a/bot/resources/halloween/bloody-pentagram.png b/bot/resources/halloween/bloody-pentagram.png deleted file mode 100644 index 4e6da07a..00000000 Binary files a/bot/resources/halloween/bloody-pentagram.png and /dev/null differ diff --git a/bot/resources/halloween/halloween_facts.json b/bot/resources/halloween/halloween_facts.json deleted file mode 100644 index fc6fa85f..00000000 --- a/bot/resources/halloween/halloween_facts.json +++ /dev/null @@ -1,14 +0,0 @@ -[ - "Halloween or Hallowe'en is also known as Allhalloween, All Hallows' Eve and All Saints' Eve.", - "It is widely believed that many Halloween traditions originated from ancient Celtic harvest festivals, particularly the Gaelic festival Samhain, which means \"summer's end\".", - "It is believed that the custom of making jack-o'-lanterns at Halloween began in Ireland. In the 19th century, turnips or mangel wurzels, hollowed out to act as lanterns and often carved with grotesque faces, were used at Halloween in parts of Ireland and the Scottish Highlands.", - "Halloween is the second highest grossing commercial holiday after Christmas.", - "The word \"witch\" comes from the Old English *wicce*, meaning \"wise woman\". In fact, *wiccan* were highly respected people at one time. According to popular belief, witches held one of their two main meetings, or *sabbats*, on Halloween night.", - "Samhainophobia is the fear of Halloween.", - "The owl is a popular Halloween image. In Medieval Europe, owls were thought to be witches, and to hear an owl's call meant someone was about to die.", - "An Irish legend about jack-o'-lanterns goes as follows:\n*On route home after a night's drinking, Jack encounters the Devil and tricks him into climbing a tree. A quick-thinking Jack etches the sign of the cross into the bark, thus trapping the Devil. Jack strikes a bargain that Satan can never claim his soul. After a life of sin, drink, and mendacity, Jack is refused entry to heaven when he dies. Keeping his promise, the Devil refuses to let Jack into hell and throws a live coal straight from the fires of hell at him. It was a cold night, so Jack places the coal in a hollowed out turnip to stop it from going out, since which time Jack and his lantern have been roaming looking for a place to rest.*", - "Trick-or-treating evolved from the ancient Celtic tradition of putting out treats and food to placate spirits who roamed the streets at Samhain, a sacred festival that marked the end of the Celtic calendar year.", - "Comedian John Evans once quipped: \"What do you get if you divide the circumference of a jack-o’-lantern by its diameter? Pumpkin π.\"", - "Dressing up as ghouls and other spooks originated from the ancient Celtic tradition of townspeople disguising themselves as demons and spirits. The Celts believed that disguising themselves this way would allow them to escape the notice of the real spirits wandering the streets during Samhain.", - "In Western history, black cats have typically been looked upon as a symbol of evil omens, specifically being suspected of being the familiars of witches, or actually shape-shifting witches themselves. They are, however, too cute to be evil." -] diff --git a/bot/resources/halloween/halloweenify.json b/bot/resources/halloween/halloweenify.json deleted file mode 100644 index af9204b2..00000000 --- a/bot/resources/halloween/halloweenify.json +++ /dev/null @@ -1,82 +0,0 @@ -{ - "characters": [ - { - "Michael Myers": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f57x2f9353301x2fwzqoqvitx2fuqkpimt-ugmza-x78pwbw-c95x3fex3d438x26yx3d38x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax26q98k.uizsx3dquiom_$/$/$/$/$/$" - }, - { - "Jason Voorhees": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f42x2f9050161x2fwzqoqvitx2friawv-dwwzpmma-nqtu-kpizikbmza-x78pwbw-c1x3fex3d438x26yx3d38x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax26q98k.uizsx3dquiom_$/$/$/$/$/$" - }, - { - "Freddy Krueger": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fvwlm_quox2f30x2f9800154x2fwzqoqvitx2fnzmllg-szcmomz-nqtu-kpizikbmza-x78pwbw-9x3fex3d438x26yx3d38x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax26q98k.uizsx3dquiom_$/$/$/$/$/$" - }, - { - "Pennywise the Dancing Clown": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f436x2f91927608x2fwzqoqvitx2fx78mvvgeqam-bpm-livkqvo-ktwev-nqtu-kpizikbmza-x78pwbw-c0x3fex3d438x26yx3d38x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax26q98k.uizsx3dquiom_$/$/$/$/$/$" - }, - { - "Charles Lee Ray": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f14x2f581261x2fwzqoqvitx2fkpiztma-tmm-zig-c90x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" - }, - { - "Leatherface": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f50x2f9204163x2fwzqoqvitx2ftmibpmznikm-c08x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" - }, - { - "Jack Torrance": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fvwlm_quox2f41x2f9032825x2fwzqoqvitx2friks-bwzzivkm-nqtu-kpizikbmza-x78pwbw-9x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" - }, - { - "Ghostface": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f32x2f9845748x2fwzqoqvitx2fopwabnikm-nqtu-kpizikbmza-x78pwbw-c9x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" - }, - { - "Regan MacNeil": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f73x2f9665843x2fwzqoqvitx2fzmoiv-uikvmqt-zmkwzlqvo-izbqaba-ivl-ozwcx78a-x78pwbw-c1x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" - }, - { - "Hannibal Lecter": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f35x2f9903368x2fwzqoqvitx2fpivvqjit-tmkbmz-nqtu-kpizikbmza-x78pwbw-c99x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" - }, - { - "Samara Morgan": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f77x2f9744082x2fwzqoqvitx2faiuizi-uwzoiv-nqtu-kpizikbmza-x78pwbw-c9x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" - }, - { - "Pinhead": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f79x2f9683232x2fwzqoqvitx2fx78qvpmil-nqtu-kpizikbmza-x78pwbw-c4x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" - }, - { - "Bruce": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f2047x2f63159728x2fwzqoqvitx2friea-c3x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" - }, - { - "Xenomorph": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f909x2f0297919x2fwzqoqvitx2ffmvwuwzx78p-x78pwbw-c91x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" - }, - { - "The Thing": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f1947x2f41148806x2fwzqoqvitx2fbpm-bpqvo-x78pwbw-c6x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" - }, - { - "Sadako Yamamura": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f76x2f9735755x2fwzqoqvitx2failisw-giuiuczi-c4x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" - }, - { - "Jigsaw Killer": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fvwlm_quox2f43x2f9065767x2fwzqoqvitx2frqoaie-sqttmz-nqtu-kpizikbmza-x78pwbw-9x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" - }, - { - "Norman Bates": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f63x2f9467317x2fwzqoqvitx2fvwzuiv-jibma-nqtu-kpizikbmza-x78pwbw-c9x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" - }, - { - "Candyman": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f255x2f7305168x2fwzqoqvitx2fkivlguiv-c5x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" - }, - { - "The Creeper": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fvwlm_quox2f998x2f0963452x2fwzqoqvitx2fbpm-kzmmx78mz-nqtu-kpizikbmza-x78pwbw-9x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" - }, - { - "Damien Thorn": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f29x2f685981x2fwzqoqvitx2fliuqmv-bpwzv-x78pwbw-c1x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" - }, - { - "Joker": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f903x2f0276049x2fwzqoqvitx2frwsmz-c71x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" - }, - { - "Cujo": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f1581x2f52837948x2fwzqoqvitx2fkcrw-nqtu-kpizikbmza-x78pwbw-c9x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" - }, - { - "Gage Creed": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f1968x2f41364699x2fwzqoqvitx2foiom-kzmml-nqtu-kpizikbmza-x78pwbw-c9x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" - }, - { - "Chatterer": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fvwlm_quox2f14x2f586061x2fwzqoqvitx2fkpibbmzmz-nqtu-kpizikbmza-x78pwbw-9x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" - }, - { - "Pale Man": "https://i2.wp.com/macguff.in/wp-content/uploads/2016/10/Pans-Labyrinth-Movie-Header-Image.jpg?fit=630%2C400&ssl=1" - } - ] -} diff --git a/bot/resources/halloween/monster.json b/bot/resources/halloween/monster.json deleted file mode 100644 index 5958dc9c..00000000 --- a/bot/resources/halloween/monster.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "monster_type": [ - ["El", "Go", "Ma", "Nya", "Wo", "Hom", "Shar", "Gronn", "Grom", "Blar"], - ["gaf", "mot", "phi", "zyme", "qur", "tile", "pim"], - ["yam", "ja", "rok", "pym", "el"], - ["ya", "tor", "tir", "tyre", "pam"] - ], - "scientist_first_name": ["Ellis", "Elliot", "Rick", "Laurent", "Morgan", "Sophia", "Oak"], - "scientist_last_name": ["E. M.", "E. T.", "Smith", "Schimm", "Schiftner", "Smile", "Tomson", "Thompson", "Huffson", "Argor", "Lephtain", "S. M.", "A. R.", "P. G."], - "verb": [ - "discovered", "created", "found" - ], - "adjective": [ - "ferocious", "spectacular", "incredible", "terrifying" - ], - "physical_adjective": [ - "springy", "rubbery", "bouncy", "tough", "notched", "chipped" - ], - "color": [ - "blue", "green", "teal", "black", "pure white", "obsidian black", "purple", "bright red", "bright yellow" - ], - "attribute": [ - "horns", "teeth", "shell", "fur", "bones", "exoskeleton", "spikes" - ], - "ability": [ - "breathe fire", "devour dreams", "lift thousand-pound weights", "devour metal", "chew up diamonds", "create diamonds", "create gemstones", "breathe icy cold air", "spit poison", "live forever" - ], - "ingredients": [ - "witch's eye", "frog legs", "slime", "true love's kiss", "a lock of golden hair", "the skin of a snake", "a never-melting chunk of ice" - ], - "time": [ - "dusk", "dawn", "mid-day", "midnight on a full moon", "midnight on Halloween night", "the time of a solar eclipse", "the time of a lunar eclipse." - ], - "year": [ - "1996", "1594", "1330", "1700" - ], - "biography_text": [ - {"scientist_first_name": 1, "scientist_last_name": 1, "verb": 1, "adjective": 1, "attribute": 1, "ability": 1, "color": 1, "year": 1, "time": 1, "physical_adjective": 1, "text": "Your name is {monster_name}, a member of the {adjective} species {monster_species}. The first {monster_species} was {verb} by {scientist_first_name} {scientist_last_name} in {year} at {time}. The species {monster_species} is known for its {physical_adjective} {color} {attribute}. It is said to even be able to {ability}!"}, - {"scientist_first_name": 1, "scientist_last_name": 1, "adjective": 1, "attribute": 1, "physical_adjective": 1, "ingredients": 2, "time": 1, "ability": 1, "verb": 1, "color": 1, "year": 1, "text": "The {monster_species} is an {adjective} species, and you, {monster_name}, are no exception. {monster_species} is famed for its {physical_adjective} {attribute}. Whispers say that when brewed with {ingredients[0]} and {ingredients[1]} at {time}, a foul, {color} brew will be produced, granting it's drinker the ability to {ability}! This species was {verb} by {scientist_first_name} {scientist_last_name} in {year}."} - ] -} diff --git a/bot/resources/halloween/monstersurvey.json b/bot/resources/halloween/monstersurvey.json deleted file mode 100644 index d8cc72e7..00000000 --- a/bot/resources/halloween/monstersurvey.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "frankenstein": { - "full_name": "Frankenstein's Monster", - "summary": "His limbs were in proportion, and I had selected his features as beautiful. Beautiful! Great God! His yellow skin scarcely covered the work of muscles and arteries beneath; his hair was of a lustrous black, and flowing; his teeth of a pearly whiteness; but these luxuriances only formed a more horrid contrast with his watery eyes, that seemed almost of the same colour as the dun-white sockets in which they were set, his shrivelled complexion and straight black lips.", - "image": "https://upload.wikimedia.org/wikipedia/commons/a/a7/Frankenstein%27s_monster_%28Boris_Karloff%29.jpg", - "votes": [] - }, - "dracula": { - "full_name": "Count Dracula", - "summary": "Count Dracula is an undead, centuries-old vampire, and a Transylvanian nobleman who claims to be a Sz\u00c3\u00a9kely descended from Attila the Hun. He inhabits a decaying castle in the Carpathian Mountains near the Borgo Pass. Unlike the vampires of Eastern European folklore, which are portrayed as repulsive, corpse-like creatures, Dracula wears a veneer of aristocratic charm. In his conversations with Jonathan Harker, he reveals himself as deeply proud of his boyar heritage and nostalgic for the past, which he admits have become only a memory of heroism, honour and valour in modern times.", - "image": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/90/Bela_Lugosi_as_Dracula%2C_anonymous_photograph_from_1931%2C_Universal_Studios.jpg/250px-Bela_Lugosi_as_Dracula%2C_anonymous_photograph_from_1931%2C_Universal_Studios.jpg", - "votes": [ - ] - }, - "goofy": { - "full_name": "Goofy in the Monster's INC World", - "summary": "Pure nightmare fuel.\nThis monster is nothing like its original counterpart. With two different eyes, a pointed nose, fins growing out of its blue skin, and dark spots covering his body, he's a true nightmare come to life.", - "image": "https://www.dailydot.com/wp-content/uploads/3a2/a8/bf38aedbef9f795f.png", - "votes": [] - }, - "refisio": { - "full_name": "Refisio", - "summary": "Who let this guy write this? That's who the real monster is.", - "image": "https://avatars0.githubusercontent.com/u/24819750?s=460&v=4", - "votes": [ - ] - } -} diff --git a/bot/resources/halloween/responses.json b/bot/resources/halloween/responses.json deleted file mode 100644 index c0f24c1a..00000000 --- a/bot/resources/halloween/responses.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "responses": [ - ["No."], - ["Yes."], - ["I will seek and answer from the devil...", "...after requesting the devils knowledge the answer is far more complicated than a simple yes or no."], - ["This knowledge is not available to me, I will seek the answers from someone far more powerful...", "...there is no answer to this question, not even the Grim Reaper could find an answer."], - ["The ghosts I summoned have confirmed that is is certain."], - ["Double, double, toil and trouble,\nFire burn and cauldron bubble.\nCool it with a baboon's blood,\nand tell me the answer to his question...", "...the great cauldron can only confirm your beliefs."], - ["Double, double, toil and trouble,\nFire burn and cauldron bubble.\nCool it with a baboon's blood,\nand tell me the answer to his question...", "...the great cauldron can only confirm that the answer to your question is no."], - ["If I tell you I will have to kill you..."], - ["I swear on my spider that you are correct."], - ["With great certainty, under the watch of the Pumpkin King, I can confirm your suspicions."], - ["The undead have sworn me to secrecy. I can not answer your question."] -]} diff --git a/bot/resources/halloween/spooky_rating.json b/bot/resources/halloween/spooky_rating.json deleted file mode 100644 index 8e3e66bb..00000000 --- a/bot/resources/halloween/spooky_rating.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "-1": { - "title": "\uD83D\uDD6F You're not scarin' anyone \uD83D\uDD6F", - "text": "No matter what you say or do, nobody even flinches when you try to scare them. Was your costume this year only a white sheet with holes for eyes? Or did you even bother with a costume at all? Either way, don't expect too many treats when going from door-to-door.", - "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/halloween/spookyrating/candle.jpeg" - }, - "5": { - "title": "\uD83D\uDC76 Like taking candy from a baby \uD83D\uDC76", - "text": "Your scaring will probably make a baby cry... but that's the limit on your frightening powers. Be careful not to get to the point where everyone's running away from you because they don't like you, not because they're scared of you.", - "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/halloween/spookyrating/baby.jpeg" - }, - "20": { - "title": "\uD83C\uDFDA You're skills are forming... \uD83C\uDFDA", - "text": "As you become the Devil's apprentice, you begin to make people jump every time you sneak up on them. A good start, but you have to learn not to wear the same costume every year until it doesn't fit you. People will notice you and your prowess will decrease.", - "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/halloween/spookyrating/tiger.jpeg" - }, - "30": { - "title": "\uD83D\uDC80 Picture Perfect... \uD83D\uDC80", - "text": "You've nailed the costume this year! You look suuuper scary! Now make sure to play the part and act out your costume and you'll be sure to give a few people a massive fright!", - "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/halloween/spookyrating/costume.jpeg" - }, - "50": { - "title": "\uD83D\uDC7B Uhm... are you human \uD83D\uDC7B", - "text": "Uhm... you're too good to be human and now you're beginning to sound like a ghost. You're almost invisible when haunting and nobody truly knows where you are at any given time. But they will always scream at the sound of a ghost...", - "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/halloween/spookyrating/ghost.jpeg" - }, - "65": { - "title": "\uD83C\uDF83 That potion can't be real \uD83C\uDF83", - "text": "You're carrying... some... unknown liquids and no one knows who they are but yourself. Be careful on who you use these powerful spells on, because no Mage has the power to do any irreversible enchantments because even you won't know what will happen to these mortals.", - "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/halloween/spookyrating/necromancer.jepg" - }, - "80": { - "title": "\uD83E\uDD21 The most sinister face \uD83E\uDD21", - "text": "Who knew something intended to be playful could be so menacing... Especially other people seeing you in their nightmares, continuing to haunt them day by day, stuck in their head throughout the entire year. Make sure to pull a face they will never forget.", - "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/halloween/spookyrating/clown.jpeg" - }, - "95": { - "title": "\uD83D\uDE08 The Devil's Accomplice \uD83D\uDE08", - "text": "Imagine being allies with the most evil character with an aim to scare people to death. Force people to suffer as they proceed straight to hell to meet your boss and best friend. Not even you know the power He has...", - "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/halloween/spookyrating/jackolantern.jpg" - }, - "100": { - "title":"\uD83D\uDC7F The Devil Himself \uD83D\uDC7F", - "text": "You are the evillest creature in existence to scare anyone and everyone humanly possible. The reason your underlings are called mortals is that they die. With your help, they die a lot quicker. With all the evil power in the universe, you know what to do.", - "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/halloween/spookyrating/devil.jpeg" - } -} diff --git a/bot/resources/halloween/spookynamerate_names.json b/bot/resources/halloween/spookynamerate_names.json deleted file mode 100644 index 7657880b..00000000 --- a/bot/resources/halloween/spookynamerate_names.json +++ /dev/null @@ -1,2206 +0,0 @@ -{ - "first_names": [ - "Eberhard", - "Gladys", - "Joshua", - "Misty", - "Bondy", - "Constantine", - "Juliette", - "Dalis", - "Nap", - "Sandy", - "Inglebert", - "Sasha", - "Julietta", - "Christoforo", - "Del", - "Zelma", - "Vladimir", - "Wayland", - "Enos", - "Siobhan", - "Farrand", - "Ailee", - "Horatia", - "Gloriana", - "Britney", - "Shel", - "Lindsey", - "Francis", - "Elsa", - "Fred", - "Upton", - "Lothaire", - "Cara", - "Margarete", - "Wolfgang", - "Charin", - "Loydie", - "Aurelea", - "Sibel", - "Glenden", - "Julian", - "Roby", - "Gerri", - "Sandie", - "Twila", - "Shaylyn", - "Clyde", - "Dina", - "Chase", - "Caron", - "Carlin", - "Aida", - "Rhonda", - "Rebekkah", - "Charmian", - "Lindy", - "Obadiah", - "Willy", - "Matti", - "Melodie", - "Ira", - "Wilfrid", - "Berton", - "Denver", - "Clarette", - "Nicolas", - "Tawnya", - "Cynthy", - "Arman", - "Sherwood", - "Flemming", - "Berri", - "Beret", - "Aili", - "Hannie", - "Eadie", - "Tannie", - "Gilda", - "Walton", - "Nolly", - "Tonya", - "Meaghan", - "Timmi", - "Faina", - "Sarge", - "Britteny", - "Farlay", - "Carola", - "Skippy", - "Corrina", - "Hans", - "Courtnay", - "Taffy", - "Averill", - "Martie", - "Tobye", - "Broderic", - "Gardner", - "Lucky", - "Beverie", - "Ignaz", - "Siana", - "Marybelle", - "Leif", - "Baily", - "Pyotr", - "Myrtle", - "Darb", - "Gar", - "Vinni", - "Samson", - "Kinny", - "Briant", - "Verney", - "Del", - "Marion", - "Beniamino", - "Nona", - "Fay", - "Noreen", - "Maurizio", - "Nial", - "Mirabella", - "Melisa", - "Anatol", - "Halette", - "Johnathon", - "Antonietta", - "Germana", - "Towny", - "Shayne", - "Court", - "Merrile", - "Staffard", - "Odele", - "Gustav", - "Moyna", - "Warden", - "Craggie", - "Hurleigh", - "Hartley", - "Rustie", - "Raven", - "Farra", - "Leonidas", - "Jorrie", - "Maximilian", - "Augustin", - "Cordelia", - "Christoffer", - "Lana", - "Vittorio", - "Janith", - "Margaret", - "Bethanne", - "Brooke", - "Payton", - "Poul", - "Diahann", - "Andy", - "Garek", - "Isa", - "Dunn", - "Anny", - "Hillary", - "Andres", - "Winn", - "Gare", - "Ameline", - "Audre", - "Rodrigo", - "Anabal", - "Reuben", - "Cecil", - "Alexandro", - "Corny", - "Erek", - "William", - "Rudyard", - "Muffin", - "Allin", - "Emmit", - "Heindrick", - "Myrna", - "Kriste", - "Perry", - "Annmarie", - "Jasun", - "Esdras", - "Jobyna", - "Marian", - "Theodore", - "Dionisio", - "Efren", - "Clarita", - "Leilah", - "Modestia", - "Clem", - "Jemmy", - "Karol", - "Minni", - "Damien", - "Tessy", - "Roanne", - "Daniele", - "Camel", - "Charlot", - "Daron", - "Cherey", - "Ashil", - "Joel", - "Michell", - "Sukey", - "Micheil", - "Chev", - "Winny", - "Natale", - "Kendra", - "Bell", - "Darice", - "Beilul", - "Leonore", - "Abba", - "Warden", - "Bryna", - "Sammy", - "Brantley", - "Goldi", - "Meridith", - "Eleanor", - "Brear", - "Kristina", - "Muriel", - "Serge", - "Iver", - "Jonis", - "Ada", - "Marleen", - "Pavlov", - "Kellia", - "Abdel", - "Waylin", - "Ignazio", - "Tana", - "Kiley", - "Lynna", - "Peyton", - "Linoel", - "Patrice", - "Loria", - "Linda", - "Edna", - "Viki", - "Kelcy", - "Chelsae", - "Olga", - "Trace", - "Ethel", - "Giorgio", - "Geralda", - "Rosaline", - "Caralie", - "Duke", - "Sig", - "Seana", - "Boris", - "Jeanie", - "Stacee", - "Giffie", - "Myrta", - "Prescott", - "Roger", - "Ame", - "Lelia", - "Marthena", - "Mord", - "Tommi", - "Artemus", - "Wynn", - "Rodi", - "Denna", - "Joleen", - "Iris", - "Pascale", - "Cody", - "Kienan", - "Darline", - "Lanna", - "Chandra", - "Michel", - "Nanete", - "Rosana", - "Ondrea", - "Linette", - "Syd", - "Rhianon", - "Christiano", - "Moyna", - "Darbee", - "Chadd", - "Roselia", - "Niki", - "Flint", - "Natala", - "Merrie", - "Noelyn", - "Arvin", - "Vin", - "Khalil", - "Nance", - "Seward", - "Dagmar", - "Shanta", - "Noland", - "Vance", - "Kyla", - "Locke", - "Abagail", - "Guthrey", - "Thalia", - "Devlen", - "Parrnell", - "Leonard", - "Amber", - "Dell", - "Lolita", - "Revkah", - "Ronna", - "Ninnetta", - "Jobey", - "Larisa", - "Wendel", - "Sonnnie", - "Saul", - "Lem", - "Wang", - "Borg", - "Korie", - "Rosanna", - "Barnaby", - "Channa", - "Gordan", - "Wang", - "Dasi", - "Laurianne", - "Jo ann", - "Bond", - "Kean", - "Harwell", - "Abbey", - "Carlo", - "Hamil", - "Ameline", - "Tristam", - "Donn", - "Earle", - "Lanie", - "Maximilianus", - "Frieda", - "Noella", - "Orsa", - "Timmi", - "Linea", - "Claudina", - "Langsdon", - "Murdock", - "Cello", - "Lek", - "Viviyan", - "Candra", - "Erena", - "Shirline", - "Mariann", - "Keelby", - "Jacquelin", - "Clerissa", - "Davis", - "Ara", - "My", - "Andris", - "Drugi", - "Lynn", - "Andonis", - "Jamie", - "Cherise", - "Lonni", - "Reamonn", - "Cathee", - "Clarence", - "Joletta", - "Tanny", - "Gasparo", - "Heddie", - "Cullin", - "Sander", - "Emmalee", - "Gwendolin", - "Hayley", - "Mandie", - "Cassondra", - "Celestyna", - "Fanny", - "Alica", - "Vivyan", - "Kippy", - "Leandra", - "Jerry", - "Elspeth", - "Lexine", - "Tobie", - "Allin", - "Ambros", - "Ash", - "Conroy", - "Melonie", - "Aylmer", - "Maximo", - "Connie", - "Torre", - "Tammie", - "Corabella", - "Beau", - "Nancee", - "Ailbert", - "Florrie", - "Trevar", - "Tiffani", - "Dre", - "Eward", - "Hallie", - "Stesha", - "Ralina", - "Vinni", - "Bastien", - "Galvan", - "Romain", - "Yasmin", - "Theodoric", - "Maxy", - "Lesly", - "Gerald", - "Erskine", - "Joice", - "Theadora", - "Sheeree", - "Danit", - "Burr", - "Morten", - "Godfree", - "Lacey", - "Sandye", - "Louisa", - "Annora", - "Rochester", - "Saundra", - "Deeann", - "Aloisia", - "Oralle", - "Ree", - "Kaile", - "Rogerio", - "Graeme", - "Garald", - "Hulda", - "Deny", - "Bessy", - "Zarah", - "Melisande", - "Taffy", - "Jed", - "Bar", - "Jacki", - "Avictor", - "Damiano", - "Yasmeen", - "Geralda", - "Kermie", - "Verge", - "Cyril", - "Klara", - "Anna", - "Abey", - "Mariellen", - "Mirabel", - "Charmain", - "Carleton", - "Biddie", - "Junina", - "Cass", - "Jdavie", - "Laird", - "Olenka", - "Dion", - "Hedy", - "Haley", - "Stacy", - "Alis", - "Morena", - "Damita", - "Wynn", - "Kellia", - "Midge", - "Gerri", - "Symon", - "Markus", - "Brenn", - "Rancell", - "Marlon", - "Dulciana", - "Lemmy", - "Neale", - "Vladamir", - "Alasteir", - "Gilberta", - "Seumas", - "Ronda", - "Myrvyn", - "Gabey", - "Goldia", - "Lothaire", - "Averil", - "Marlo", - "Nanice", - "Bernadette", - "Nehemiah", - "Ivar", - "Natala", - "Dorthy", - "Melva", - "Alisha", - "Ruthann", - "Ray", - "Ariel", - "Gib", - "Pippo", - "Miner", - "Ardith", - "Letisha", - "Granger", - "Sue", - "Toby", - "Tallou", - "Stephi", - "Hunter", - "Terrell", - "Pail", - "Moise", - "Rosetta", - "Ira", - "Denyse", - "Jackie", - "Fons", - "Goldy", - "Rani", - "Bendick", - "Valentijn", - "Annabell", - "Ardith", - "Lesly", - "Almire", - "Emmalyn", - "Mechelle", - "Anna", - "Duff", - "Louise", - "Vivian", - "Farand", - "Sophi", - "Thedric", - "Vivien", - "Jere", - "Kassie", - "Andy", - "Helli", - "Ros", - "Babara", - "Othella", - "Shelton", - "Hector", - "Charmian", - "Rosamond", - "Maison", - "Magda", - "Gustave", - "Latisha", - "Erik", - "Gavin", - "Bobette", - "Masha", - "Collie", - "Kippie", - "Jillayne", - "Fairfax", - "Ulrika", - "Juliann", - "Joly", - "Aldus", - "Clarie", - "Aluin", - "Claudetta", - "Noella", - "Nichols", - "Rutger", - "Niall", - "Hunter", - "Hyacinthia", - "Eva", - "Humphrey", - "Randi", - "Leontyne", - "Bordy", - "Orin", - "Tobey", - "Aldis", - "Vernon", - "Griz", - "Dynah", - "Ann-marie", - "Inglebert", - "Gifford", - "Emeline", - "Shem", - "Sigvard", - "Mayne", - "Rhodia", - "Seward", - "Valencia", - "Babara", - "Cirstoforo", - "Nye", - "Merissa", - "Lucinda", - "Wynn", - "Vassili", - "Cletus", - "Felisha", - "Laural", - "William", - "Emmalynne", - "Angy", - "Charles", - "Jemmy", - "Edward", - "Millicent", - "Homer", - "Allie", - "Brandyn", - "Dannye", - "Hector", - "Fawne", - "Frayda", - "Issiah", - "Deana", - "Bearnard", - "Ken", - "Sinclare", - "Mallorie", - "Noby", - "Deonne", - "Brig", - "Ruy", - "Vivia", - "Nyssa", - "Ame", - "Carmen", - "Solly", - "Carolee", - "Felice", - "Claiborne", - "Layney", - "Raina", - "Tami", - "Dosi", - "Barth", - "Julita", - "Gardiner", - "Stesha", - "Geneva", - "Saudra", - "Ella", - "Welbie", - "Marya", - "Happy", - "Brandise", - "Jewell", - "Joana", - "Eddy", - "Buck", - "Leslie", - "Yolanda", - "Murdoch", - "Muffin", - "Myrna", - "Susi", - "Berthe", - "Debra", - "Kyla", - "Bron", - "Thurston", - "Case", - "Shelli", - "Danika", - "Charissa", - "Wylie", - "Corine", - "Caitrin", - "Atalanta", - "Vevay", - "Thekla", - "Inez", - "Pris", - "Zsazsa", - "Ardenia", - "Ole", - "Kelcy", - "Earl", - "Pierson", - "Opalina", - "Leta", - "Keefer", - "Conrado", - "Chen", - "Alys", - "Floyd", - "Kai", - "Warden", - "Peyton", - "Debora", - "Walton", - "Fionna", - "Kendra", - "Michail", - "Christa", - "Theodor", - "Avivah", - "Patric", - "Quinton", - "Fey", - "Lewiss", - "Loren", - "Nedi", - "Fergus", - "Jeanie", - "Liuka", - "Ashley", - "Ellsworth", - "Winslow", - "Land", - "Rooney", - "Kati", - "Joelie", - "Garner", - "Clarice", - "Clair", - "Heddi", - "Ivan", - "Enrichetta", - "Umberto", - "Alys", - "Marcellina", - "Elnore", - "Wilburt", - "Ami", - "Meridith", - "Devlin", - "Cicely", - "Nathanael", - "Rafi", - "Arluene", - "Erasmus", - "Tasia", - "Seumas", - "George", - "Fredrika", - "Jayne", - "Linus", - "Mathilde", - "Klarrisa", - "Willy", - "Rad", - "Rae", - "Wilfred", - "Amberly", - "Paulo", - "Robbi", - "Gladys", - "Mirilla", - "Danica", - "Montgomery", - "Bellina", - "Neill", - "Roddie", - "Sebastiano", - "Adrianne", - "Gilli", - "Rhodia", - "Orbadiah", - "Levy", - "Griswold", - "Millicent", - "Carry", - "Alexander", - "Carole", - "Othilie", - "Enrica", - "Corissa", - "Meaghan", - "Margret", - "Sheff", - "Walton", - "Tremain", - "Bear", - "Maximilian", - "Theodora", - "Fredric", - "Baudoin", - "Rees", - "Roldan", - "Mayor", - "Angelica", - "Clemente", - "Florencia", - "Lancelot", - "Valencia", - "Caddric", - "Frieda", - "Jarvis", - "Shamus", - "Kalindi", - "Allen", - "Maureen", - "Ax", - "Barbra", - "Craggy", - "Howie", - "Orson", - "Cammy", - "Sullivan", - "Marleen", - "Jarrad", - "Lucy", - "Catha", - "Guillemette", - "Birdie", - "Forrest", - "Luce", - "Myriam", - "Serge", - "Kali", - "Ruperto", - "Trisha", - "Shaylynn", - "Janella", - "Franciskus", - "Melinde", - "Effie", - "Letti", - "Roderic", - "Jandy", - "Michaelina", - "Mohammed", - "Dolorita", - "Elbertine", - "Esma", - "Emmett", - "Lucila", - "Joyann", - "Mufi", - "Karlotta", - "Vannie", - "Daphna", - "Blondie", - "Madelene", - "Tomkin", - "Kassie", - "Flynn", - "Zebadiah", - "Lauritz", - "Brian", - "Leah", - "Amalita", - "Corissa", - "Onfre", - "Shantee", - "Deena", - "Marena", - "Alejoa", - "Fania", - "Catha", - "Cherlyn", - "Gerrilee", - "Brook", - "Yardley", - "Karry", - "Dennis", - "Ingra", - "Damian", - "Alexandros", - "Romola", - "Grantley", - "Antons", - "Randal", - "Lorilee", - "Brier", - "Tyrone", - "Jennica", - "Deidre", - "Arlin", - "Marline", - "Lyell", - "Lorelei", - "Marius", - "Willy", - "Teddy", - "Grantham", - "Yelena", - "Jaimie", - "Brewer", - "Tess", - "Othelia", - "Bondy", - "Rebecka", - "Laurice", - "Jasen", - "Betty", - "Alverta", - "Pepita", - "Kandace", - "Loni", - "Doreen", - "Ketty", - "Ree", - "Danni", - "Zorah", - "Shayla", - "Ivy", - "Darin", - "Karie", - "Brittaney", - "Viole", - "Harlene", - "Jasun", - "Aime", - "Rickie", - "Heath", - "Andris", - "Vaughn", - "Giorgi", - "Maddalena", - "Shirley", - "Cherie", - "Zacharia", - "Darcey", - "Barbee", - "Ernest", - "Sher", - "Faustina", - "Nari", - "Gusella", - "Reginald", - "Zack", - "Michele", - "Gene", - "Lindy", - "Mirilla", - "Tudor", - "Tyler", - "Bernadina", - "Magdalen", - "Nollie", - "Coreen", - "Hoebart", - "Virginie", - "Waylin", - "Hank", - "Valenka", - "Sabine", - "Jesus", - "Annabell", - "Jesselyn", - "Marysa", - "Corbett", - "Carena", - "Bert", - "Tanhya", - "Alphonse", - "Johnette", - "Vince", - "Cordell", - "Ramonda", - "Trev", - "Glenna", - "Loy", - "Arni", - "Tedd", - "Tristam", - "Zelma", - "Emmeline", - "Ellswerth", - "Janeta", - "Hughie", - "Tarun", - "Enid", - "Rafe", - "Hal", - "Melissa", - "Layan", - "Sia", - "Horace", - "Derry", - "Kelsi", - "Zacharia", - "Tillie", - "Dillon", - "Maxwell", - "Shanai", - "Charlize", - "Usama", - "Nabeela", - "Emily-Jane", - "Martyn", - "Tre", - "Ioan", - "Elysia", - "Mikaeel", - "Danny", - "Ciaron", - "Ace", - "Amy-Louise", - "Gabrielle", - "Robbie", - "Thea", - "Gloria", - "Jana", - "Cole", - "Eamon", - "Samiyah", - "Ellie-Mai", - "Lawson", - "Gia", - "Merryn", - "Andre", - "Ansh", - "Kavita", - "Alasdair", - "Aamina", - "Donna", - "Dario", - "Sahra", - "Brittany", - "Shakeel", - "Taylor", - "Ellenor", - "Kacy", - "Gene", - "Hetty", - "Fletcher", - "Donte", - "Krisha", - "Everett", - "Leila", - "Aairah", - "Zander", - "Sakina", - "Sanaya", - "Nelly", - "Manon", - "Antonio", - "Aimie", - "Kyran", - "Daria", - "Tilly-Mae", - "Lisa", - "Ammaarah", - "Adina", - "Kaan", - "Torin", - "Sadie", - "Mia-Rose", - "Aadam", - "Phyllis", - "Jace", - "Fraser", - "Tamanna", - "Dahlia", - "Cristian", - "Maira", - "Lana", - "Lily-Mai", - "Barney", - "Beatrice", - "Tabitha", - "Anis", - "Heidi", - "Ahyan", - "Usaamah", - "Jolene", - "Melisa", - "Magdalena", - "Hina" - ], - "last_names": [ - "Silveston", - "Manson", - "Hoodlass", - "Auden", - "Speakman", - "Seavers", - "Sodeau", - "Gouth", - "Pickersail", - "Ferschke", - "Buzzing", - "Kinnar", - "Pemberton", - "Firebrace", - "Kornilyev", - "Linsley", - "Petyanin", - "McCobb", - "Disdel", - "Eskrick", - "Pringuer", - "Clavering", - "Sims", - "Lippitt", - "Springall", - "Spiteri", - "Dwyr", - "Tomas", - "Cleminson", - "Crowder", - "Juster", - "Leven", - "Doucette", - "Schimoni", - "Readwing", - "Karet", - "Reef", - "Welden", - "Bemand", - "Schulze", - "Bartul", - "Collihole", - "Thain", - "Bernhardt", - "Tolputt", - "Hedges", - "Lowne", - "Kobu", - "Cabrera", - "Gavozzi", - "Ghilardini", - "Leamon", - "Gadsden", - "Gregg", - "Tew", - "Bangle", - "Youster", - "Vince", - "Cristea", - "Ablott", - "Lightowlers", - "Kittredge", - "Armour", - "Bukowski", - "Knowlton", - "Juett", - "Santorini", - "Ends", - "Hawkings", - "Janowicz", - "Harry", - "Bougourd", - "Gillow", - "Whalebelly", - "Conneau", - "Mellows", - "Stolting", - "Stickells", - "Maryet", - "Echallie", - "Edgecombe", - "Orchart", - "Mowles", - "McGibbon", - "Titchen", - "Madgewick", - "Fairburne", - "Colgan", - "Chaudhry", - "Taks", - "Lorinez", - "Eixenberger", - "Burel", - "Chapleo", - "Margram", - "Purse", - "MacKay", - "Oxlade", - "Prahm", - "Wellbank", - "Blackborow", - "Woodbridge", - "Sodory", - "Vedmore", - "Beeckx", - "Newcomb", - "Ridel", - "Desporte", - "Jobling", - "Winear", - "Korneichuk", - "Aucott", - "Wawer", - "Aicheson", - "Hawkslee", - "Wynes", - "St. Quentin", - "McQuorkel", - "Hendrick", - "Rudsdale", - "Winsor", - "Thunders", - "Stonbridge", - "Perrie", - "D'Alessandro", - "Banasevich", - "Mc Elory", - "Cobbledick", - "Wreakes", - "Carnie", - "Pallister", - "Yeates", - "Hoovart", - "Doogood", - "Churn", - "Gillon", - "Nibley", - "Dusting", - "Melledy", - "O'Noland", - "Crosfeld", - "Pairpoint", - "Longson", - "Rodden", - "Foyston", - "Le Teve", - "Brumen", - "Pudsey", - "Klimentov", - "Agent", - "Seabert", - "Cramp", - "Bitcheno", - "Embery", - "Etheredge", - "Sheardown", - "McKune", - "Vearncomb", - "Lavington", - "Rylands", - "Derges", - "Olivetti", - "Matasov", - "Thrower", - "Jobin", - "Ramsell", - "Rude", - "Tregale", - "Bradforth", - "McQuarter", - "Walburn", - "Poad", - "Filtness", - "Carneck", - "Pavis", - "Pinchen", - "Polye", - "Abry", - "Radloff", - "McDugal", - "Loughton", - "Revitt", - "Baniard", - "Kovalski", - "Mapother", - "Hendrikse", - "Rickardsson", - "Featherbie", - "Harlow", - "Kruschov", - "McCrillis", - "Barabich", - "Peaker", - "Skamell", - "Gorges", - "Chance", - "Bresner", - "Profit", - "Swinfon", - "Goldson", - "Nunson", - "Tarling", - "Ruperti", - "Grimsell", - "Davey", - "Deetlof", - "Gave", - "Fawltey", - "Tyre", - "Whaymand", - "Trudgian", - "McAndrew", - "Aleksankov", - "Dimbleby", - "Beseke", - "Cleverley", - "Aberhart", - "Courtin", - "MacKellen", - "Johannesson", - "Churm", - "Laverock", - "Astbury", - "Canto", - "Nelles", - "Dormand", - "Blucher", - "Youngs", - "Dalrymple", - "M'Chirrie", - "Jansens", - "Golthorpp", - "Ibberson", - "Andriveau", - "Paulton", - "Parrington", - "Shergill", - "Bickerton", - "Hugonneau", - "Cornelissen", - "Spincks", - "Malkinson", - "Kettow", - "Wasiel", - "Skeat", - "Maynard", - "Goutcher", - "Cratchley", - "Loving", - "Averies", - "Cahillane", - "Alvarado", - "Truggian", - "Bravington", - "McGonigle", - "Crocombe", - "Slorance", - "Dukes", - "Nairns", - "Condict", - "Got", - "Flowerdew", - "Deboy", - "Death", - "Patroni", - "Colgrave", - "Polley", - "Spraging", - "Orteaux", - "Daskiewicz", - "Dunsmore", - "Forrington", - "De Gogay", - "Swires", - "Grimmert", - "Castells", - "Scraggs", - "Chase", - "Dixsee", - "Brennans", - "Gookes", - "MacQueen", - "Galbreth", - "Buttwell", - "Annear", - "Sutherley", - "Portis", - "Pashen", - "Blackbourn", - "Sedgemond", - "Huegett", - "Emms", - "Leifer", - "Paschek", - "Bynold", - "Mahony", - "Izacenko", - "Hadland", - "Sallows", - "Hamper", - "Godlee", - "Rablin", - "Emms", - "Zealy", - "Russi", - "Crassweller", - "Shotbolt", - "Van Der Weedenburg", - "MacGille", - "Carillo", - "Guerin", - "Cuolahan", - "Metzel", - "Martinovsky", - "Stoggles", - "Brameld", - "Coupland", - "Kaaskooper", - "Sallows", - "Rizzotto", - "Dike", - "O'Lochan", - "Spragg", - "Lavarack", - "MacNess", - "Swetenham", - "Dillet", - "Coffey", - "Meikle", - "Loynes", - "Josum", - "Adkin", - "Tompsett", - "Maclaine", - "Fippe", - "Bispo", - "Whittek", - "Rylett", - "Iveagh", - "Elgar", - "Casswell", - "Tilt", - "Macklin", - "Lillee", - "Hamshere", - "Coite", - "Dollard", - "Tiesman", - "Coltart", - "Stothert", - "Crosswaite", - "Padgett", - "Gleadle", - "Meedendorpe", - "Alexsandrovich", - "Williamson", - "Futty", - "Antwis", - "Romanski", - "Dionisetti", - "Dimitriev", - "Swalowe", - "Dewing", - "O'Driscoll", - "Jeandel", - "Summerly", - "Shoute", - "Trelevan", - "Matkin", - "Headey", - "Rosson", - "Dunn", - "Gunner", - "Stapells", - "Fratczak", - "McGillivray", - "Edis", - "Treuge", - "Haskayne", - "Perell", - "O'Fairy", - "Slisby", - "Axcell", - "Mattingley", - "Tumilty", - "Kibble", - "Lambert", - "Hassall", - "Simpkin", - "Nitti", - "Stiegar", - "Pavitt", - "Kerby", - "Ruzic", - "Westwick", - "Tonbye", - "Bocken", - "Kinforth", - "Wren", - "Attow", - "McComish", - "McNickle", - "Wildman", - "O'Corhane", - "Jewar", - "Caveau", - "Woodrooffe", - "Batson", - "Stayt", - "A'field", - "Domesday", - "Taberer", - "Gigg", - "Stanmore", - "Hanton", - "Roskell", - "Brasener", - "Stanbro", - "Cordy", - "O'Bradane", - "Hansberry", - "Erdes", - "Wagon", - "Jimmes", - "Ruffles", - "Wigginton", - "Haste", - "Rymill", - "Tomsett", - "Ambrosoli", - "Reidshaw", - "Nurcombe", - "Costigan", - "Berwick", - "Hinchon", - "Blissitt", - "Golston", - "Goullee", - "Hudspeth", - "Traher", - "Salandino", - "Fatscher", - "Davidov", - "Baukham", - "Mallan", - "Kilmurray", - "Dmych", - "Mair", - "Felmingham", - "Kedward", - "Leechman", - "Frank", - "Tremoulet", - "Manley", - "Newcom", - "Brandone", - "Cliffe", - "Shorte", - "Baalham", - "Fairhead", - "Sheal", - "Effnert", - "MacCaughey", - "Rizzolo", - "Linthead", - "Greenhouse", - "Clayson", - "Franca", - "Lambell", - "Egdal", - "Pringell", - "Penni", - "Train", - "Langfitt", - "Dady", - "Rannigan", - "Ledwidge", - "Summerton", - "D'Hooghe", - "Ary", - "Gooderick", - "Scarsbrooke", - "Janouch", - "Pond", - "Menichini", - "Crinidge", - "Sneesbie", - "Harflete", - "Ubsdell", - "Littleover", - "Vanne", - "Fassbender", - "Zellner", - "Gorce", - "McKeighan", - "Claffey", - "MacGarvey", - "Norwich", - "Antosch", - "Loughton", - "McCuthais", - "Arnaudi", - "Broz", - "Stert", - "McMechan", - "Texton", - "Bees", - "Couser", - "Easseby", - "McCorry", - "Fetterplace", - "Crankshaw", - "Spancock", - "Neasam", - "Bruckental", - "Badgers", - "Rodda", - "Bossingham", - "Crump", - "Jurgensen", - "Noyes", - "Scarman", - "Bakey", - "Swindin", - "Tolworthie", - "Vynehall", - "Shallcrass", - "Bazoge", - "Jonczyk", - "Eatherton", - "Finlason", - "Hembery", - "Lassetter", - "Soule", - "Baldocci", - "Thurman", - "Poppy", - "Eveque", - "Summerlad", - "Eberle", - "Pettecrew", - "Hitzmann", - "Allonby", - "Bodimeade", - "Catteroll", - "Wooldridge", - "Baines", - "Halloway", - "Doghartie", - "Bracher", - "Kynnd", - "Metherell", - "Routham", - "Fielder", - "Ashleigh", - "Aked", - "Kolakowski", - "Picardo", - "Murdy", - "Feacham", - "Lewin", - "Braben", - "Salaman", - "Letterick", - "Bovaird", - "Moriarty", - "Bertot", - "Cowan", - "Dionisi", - "Maybey", - "Joskowicz", - "Shoutt", - "Bernli", - "Dikles", - "Corringham", - "Shaw", - "Donovin", - "Merigeau", - "Pinckney", - "Queripel", - "Sampson", - "Benfell", - "Cansdell", - "Tasseler", - "Amthor", - "Nancekivell", - "Stock", - "Boltwood", - "Goreisr", - "Le Grand", - "Terrans", - "Knapp", - "Roseman", - "Gunstone", - "Hissie", - "Orto", - "Bell", - "Colam", - "Drust", - "Roseblade", - "Sulman", - "Jennaway", - "Joust", - "Curthoys", - "Cajkler", - "MacIllrick", - "Print", - "Coulthard", - "Lemmon", - "Bush", - "McMurrugh", - "Toping", - "Brute", - "Fryman", - "Bosomworth", - "Lawson", - "Lauder", - "Heinssen", - "Bittlestone", - "Brinson", - "Hambling", - "Vassman", - "Brookbank", - "Bolstridge", - "Leslie", - "Berndsen", - "Aindrais", - "Mogra", - "Wilson", - "Josefs", - "Norgan", - "Wong", - "le Keux", - "Hastwall", - "Bunson", - "Van", - "Waghorne", - "Ojeda", - "Boole", - "Winters", - "Gurge", - "Gallemore", - "Perulli", - "Dight", - "Di Filippo", - "Winsley", - "Chalcraft", - "Human", - "Laetham", - "Lennie", - "McSorley", - "Toolan", - "Brammar", - "Cadogan", - "Molloy", - "Shoveller", - "Vignaux", - "Hannaway", - "Sykora", - "Brealey", - "Harness", - "Profit", - "Goldsbury", - "Brands", - "Godmar", - "Binden", - "Kondratenya", - "Warsap", - "Rumble", - "Maudson", - "Demer", - "Laxtonne", - "Kmietsch", - "Colten", - "Raysdale", - "Gadd", - "Blanche", - "Viant", - "Daskiewicz", - "Macura", - "Crouch", - "Janicijevic", - "Oade", - "Fancourt", - "Dimitriev", - "Earnshaw", - "Wing", - "Fountain", - "Fearey", - "Nottram", - "Bescoby", - "Jeandeau", - "Mapowder", - "Iacobo", - "Rabjohns", - "Dean", - "Whiterod", - "Mathiasen", - "Josephson", - "Boc", - "Olivet", - "Yeardley", - "Labuschagne", - "Curmi", - "Rogger", - "Tesoe", - "Mellhuish", - "Malan", - "McArt", - "Ing", - "Renowden", - "Mellsop", - "Critchlow", - "Seedhouse", - "Tiffin", - "Chirm", - "Oldknow", - "Wolffers", - "Dainter", - "Bundy", - "Copplestone", - "Moses", - "Weedon", - "Borzone", - "Craigg", - "Pyrah", - "Shoorbrooke", - "Jeandeau", - "Halgarth", - "Bamlett", - "Greally", - "Abrahamovitz", - "Oger", - "Mandrake", - "Craigg", - "Stenning", - "Tommei", - "Mapother", - "Cree", - "Clandillon", - "Thorlby", - "Careswell", - "Woolnough", - "McMeekin", - "Woodman", - "Mougin", - "Burchill", - "Pegg", - "Morin", - "Eskriett", - "Gelderd", - "Latham", - "Siney", - "Freen", - "Walrond", - "Bell", - "Twigley", - "D'Souza", - "Anton", - "Doyle", - "Pieters", - "Rosenvasser", - "Mackneis", - "Brisse", - "Boffin", - "Rushe", - "Cozens", - "Bensusan", - "Plampin", - "Gauford", - "Lecky", - "Belton", - "Fleming", - "Gent", - "Bunclark", - "Climar", - "Milner", - "Karolovsky", - "Claesens", - "Oleksiak", - "Barkway", - "Glenister", - "Steynor", - "Hecks", - "Rollo", - "Elcoux", - "Altham", - "Veschambes", - "Livingstone", - "Miroy", - "Edy", - "Bendle", - "Widdall", - "Onions", - "Devita", - "McOwan", - "Ahearne", - "Wisniowski", - "Pask", - "Ciccottini", - "Parlatt", - "Gindghill", - "Marquess", - "Claworth", - "Veel", - "Fairbairn", - "Galletley", - "Glew", - "Gillice", - "Liddyard", - "Babin", - "Ryson", - "Kyteley", - "Toms", - "Downton", - "Mougel", - "Inglefield", - "Gaskins", - "Bradie", - "Stanbury", - "McMenamy", - "Cranstone", - "Thody", - "Iacovozzo", - "Theobalds", - "Perrins", - "Dyott", - "Hupe", - "Gelling", - "Eadington", - "Crumbie", - "Stainsby", - "Kolakowski", - "Norwich", - "Ehrat", - "Basnett", - "Marden", - "Godby", - "Kubacki", - "Wiles", - "Littrick", - "Chuck", - "Negus", - "Aisthorpe", - "Danelut", - "Helversen", - "McCombe", - "Dallender", - "Offner", - "Leser", - "Savin", - "Belcham", - "Pockett", - "Selway", - "Santostefano.", - "Telford", - "Presser", - "Haken", - "Wybourne", - "Reolfo", - "Mineghelli", - "Beverage", - "Grimsdike", - "Drogan", - "Bynert", - "Boothman", - "Postle", - "Baskwell", - "Branno", - "Hechlin", - "Geake", - "Morstatt", - "Towne", - "Phillott", - "Doumerc", - "Ladewig", - "Sexty", - "Sleigh", - "Simonaitis", - "Han", - "Crommett", - "Blowes", - "Floyde", - "Delgardo", - "Brounsell", - "Klimowski", - "Jaffray", - "Kingzeth", - "Pithie", - "Eriksson", - "Gudgin", - "Hamal", - "Hooks", - "Rosle", - "Braysher", - "O'Curneen", - "Millett", - "Woofinden", - "Lillistone", - "Broxis", - "Mochar", - "Drewell", - "Hedgeman", - "Wharf", - "Lambden", - "Lambol", - "Slowcock", - "Cicchillo", - "Trineman", - "Sinyard", - "Brandone", - "Masding", - "Britnell", - "Quinlan", - "Arnopp", - "Jeratt", - "Bantick", - "Craigs", - "Pantling", - "Klais", - "Pickvance", - "Goodwill", - "McGavin", - "Esslemont", - "Bakewell", - "Downer", - "Scallan", - "Ronchka", - "Scholcroft", - "Van Der Walt", - "Armfield", - "Chalker", - "Chinge", - "Yakubov", - "Folkerd", - "Manon", - "Gookey", - "Connold", - "Dusey", - "Muselli", - "Skala", - "Dibbin", - "Kreber", - "De Blasi", - "Drei", - "Argo", - "Maudson", - "Stanlick", - "Steinham", - "Dallewater", - "Litchmore", - "Mathie", - "Gook", - "Forrestor", - "Ferreira", - "Budd", - "Joskowitz", - "Whetnall", - "Beany", - "Keymar", - "Merrin", - "Waldera", - "O'Gleasane", - "Duiged", - "Cumo", - "Giddings", - "Craker", - "Olenov", - "Whayman", - "Raoux", - "Delete", - "McDell", - "Gauntlett", - "Gomby", - "Rottgers", - "Spraggon", - "Orth", - "Shortan", - "Lineen", - "Monkhouse", - "Di Domenico", - "Brinsden", - "MacCallister", - "Sieghard", - "Pheasant", - "Cloney", - "Igglesden", - "Checklin", - "Grosier", - "Garnett", - "Vasnetsov", - "Chsteney", - "Manifield", - "Coutts", - "Bagshawe", - "Pryn", - "Dunstall", - "Rowlings", - "Whines", - "Bish", - "Solomon", - "Mackay", - "Daugherty", - "Gutierrez", - "Goff", - "Villanueva", - "Heath", - "Serrano", - "Munro", - "Levine", - "Barrett", - "Bateman", - "Colon", - "Alford", - "Whitehouse", - "Mendoza", - "Keith", - "Orr", - "Shepherd", - "North", - "Steele", - "Morales", - "Shea", - "Olsen", - "Wormald", - "Torres", - "Haines", - "Kerr", - "Reeves", - "Bates", - "Potts", - "Foreman", - "Herrera", - "Mccoy", - "Fulton", - "Charles", - "Clay", - "Estes", - "Mata", - "Childs", - "Kendall", - "Wallace", - "Thorpe", - "Oconnell", - "Waters", - "Roth", - "Barker", - "Fritz", - "Singleton", - "Sharpe", - "Little", - "Oliver", - "Ayala", - "Khan", - "Braun", - "Dean", - "Stout", - "Adamson", - "Tate", - "Juarez", - "Pickett", - "Burke", - "Gordon", - "Mackenzie", - "Bloggs", - "Read", - "Britton", - "Jefferson", - "Lutz", - "Chen", - "Wagstaff", - "Coates", - "Gilliam", - "Mullins", - "Ryan", - "Moon", - "Thompson", - "Abbott", - "Cotton", - "Barajas", - "Chan", - "Bostock", - "Spencer", - "Sparrow", - "Robinson", - "Morrison", - "Aguirre", - "Clayton", - "Hope", - "Swanson", - "Ochoa", - "Ruiz", - "Truong", - "Gibbons", - "Daniel", - "Zimmerman", - "Flynn", - "Keeling", - "Greenaway", - "Edwards" - ] -} diff --git a/bot/resources/halloween/spookyrating/baby.jpeg b/bot/resources/halloween/spookyrating/baby.jpeg deleted file mode 100644 index 199f8bca..00000000 Binary files a/bot/resources/halloween/spookyrating/baby.jpeg and /dev/null differ diff --git a/bot/resources/halloween/spookyrating/candle.jpeg b/bot/resources/halloween/spookyrating/candle.jpeg deleted file mode 100644 index 9913752b..00000000 Binary files a/bot/resources/halloween/spookyrating/candle.jpeg and /dev/null differ diff --git a/bot/resources/halloween/spookyrating/clown.jpeg b/bot/resources/halloween/spookyrating/clown.jpeg deleted file mode 100644 index f23c4f70..00000000 Binary files a/bot/resources/halloween/spookyrating/clown.jpeg and /dev/null differ diff --git a/bot/resources/halloween/spookyrating/costume.jpeg b/bot/resources/halloween/spookyrating/costume.jpeg deleted file mode 100644 index b3c21af0..00000000 Binary files a/bot/resources/halloween/spookyrating/costume.jpeg and /dev/null differ diff --git a/bot/resources/halloween/spookyrating/devil.jpeg b/bot/resources/halloween/spookyrating/devil.jpeg deleted file mode 100644 index 4f45aaa7..00000000 Binary files a/bot/resources/halloween/spookyrating/devil.jpeg and /dev/null differ diff --git a/bot/resources/halloween/spookyrating/ghost.jpeg b/bot/resources/halloween/spookyrating/ghost.jpeg deleted file mode 100644 index 0cb13346..00000000 Binary files a/bot/resources/halloween/spookyrating/ghost.jpeg and /dev/null differ diff --git a/bot/resources/halloween/spookyrating/jackolantern.jpeg b/bot/resources/halloween/spookyrating/jackolantern.jpeg deleted file mode 100644 index d7cf3d08..00000000 Binary files a/bot/resources/halloween/spookyrating/jackolantern.jpeg and /dev/null differ diff --git a/bot/resources/halloween/spookyrating/necromancer.jpeg b/bot/resources/halloween/spookyrating/necromancer.jpeg deleted file mode 100644 index 60b1e689..00000000 Binary files a/bot/resources/halloween/spookyrating/necromancer.jpeg and /dev/null differ diff --git a/bot/resources/halloween/spookyrating/tiger.jpeg b/bot/resources/halloween/spookyrating/tiger.jpeg deleted file mode 100644 index 0419f5df..00000000 Binary files a/bot/resources/halloween/spookyrating/tiger.jpeg and /dev/null differ diff --git a/bot/resources/holidays/halloween/bat-clipart.png b/bot/resources/holidays/halloween/bat-clipart.png new file mode 100644 index 00000000..7df26ba9 Binary files /dev/null and b/bot/resources/holidays/halloween/bat-clipart.png differ diff --git a/bot/resources/holidays/halloween/bloody-pentagram.png b/bot/resources/holidays/halloween/bloody-pentagram.png new file mode 100644 index 00000000..4e6da07a Binary files /dev/null and b/bot/resources/holidays/halloween/bloody-pentagram.png differ diff --git a/bot/resources/holidays/halloween/halloween_facts.json b/bot/resources/holidays/halloween/halloween_facts.json new file mode 100644 index 00000000..fc6fa85f --- /dev/null +++ b/bot/resources/holidays/halloween/halloween_facts.json @@ -0,0 +1,14 @@ +[ + "Halloween or Hallowe'en is also known as Allhalloween, All Hallows' Eve and All Saints' Eve.", + "It is widely believed that many Halloween traditions originated from ancient Celtic harvest festivals, particularly the Gaelic festival Samhain, which means \"summer's end\".", + "It is believed that the custom of making jack-o'-lanterns at Halloween began in Ireland. In the 19th century, turnips or mangel wurzels, hollowed out to act as lanterns and often carved with grotesque faces, were used at Halloween in parts of Ireland and the Scottish Highlands.", + "Halloween is the second highest grossing commercial holiday after Christmas.", + "The word \"witch\" comes from the Old English *wicce*, meaning \"wise woman\". In fact, *wiccan* were highly respected people at one time. According to popular belief, witches held one of their two main meetings, or *sabbats*, on Halloween night.", + "Samhainophobia is the fear of Halloween.", + "The owl is a popular Halloween image. In Medieval Europe, owls were thought to be witches, and to hear an owl's call meant someone was about to die.", + "An Irish legend about jack-o'-lanterns goes as follows:\n*On route home after a night's drinking, Jack encounters the Devil and tricks him into climbing a tree. A quick-thinking Jack etches the sign of the cross into the bark, thus trapping the Devil. Jack strikes a bargain that Satan can never claim his soul. After a life of sin, drink, and mendacity, Jack is refused entry to heaven when he dies. Keeping his promise, the Devil refuses to let Jack into hell and throws a live coal straight from the fires of hell at him. It was a cold night, so Jack places the coal in a hollowed out turnip to stop it from going out, since which time Jack and his lantern have been roaming looking for a place to rest.*", + "Trick-or-treating evolved from the ancient Celtic tradition of putting out treats and food to placate spirits who roamed the streets at Samhain, a sacred festival that marked the end of the Celtic calendar year.", + "Comedian John Evans once quipped: \"What do you get if you divide the circumference of a jack-o’-lantern by its diameter? Pumpkin π.\"", + "Dressing up as ghouls and other spooks originated from the ancient Celtic tradition of townspeople disguising themselves as demons and spirits. The Celts believed that disguising themselves this way would allow them to escape the notice of the real spirits wandering the streets during Samhain.", + "In Western history, black cats have typically been looked upon as a symbol of evil omens, specifically being suspected of being the familiars of witches, or actually shape-shifting witches themselves. They are, however, too cute to be evil." +] diff --git a/bot/resources/holidays/halloween/halloweenify.json b/bot/resources/holidays/halloween/halloweenify.json new file mode 100644 index 00000000..af9204b2 --- /dev/null +++ b/bot/resources/holidays/halloween/halloweenify.json @@ -0,0 +1,82 @@ +{ + "characters": [ + { + "Michael Myers": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f57x2f9353301x2fwzqoqvitx2fuqkpimt-ugmza-x78pwbw-c95x3fex3d438x26yx3d38x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax26q98k.uizsx3dquiom_$/$/$/$/$/$" + }, + { + "Jason Voorhees": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f42x2f9050161x2fwzqoqvitx2friawv-dwwzpmma-nqtu-kpizikbmza-x78pwbw-c1x3fex3d438x26yx3d38x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax26q98k.uizsx3dquiom_$/$/$/$/$/$" + }, + { + "Freddy Krueger": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fvwlm_quox2f30x2f9800154x2fwzqoqvitx2fnzmllg-szcmomz-nqtu-kpizikbmza-x78pwbw-9x3fex3d438x26yx3d38x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax26q98k.uizsx3dquiom_$/$/$/$/$/$" + }, + { + "Pennywise the Dancing Clown": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f436x2f91927608x2fwzqoqvitx2fx78mvvgeqam-bpm-livkqvo-ktwev-nqtu-kpizikbmza-x78pwbw-c0x3fex3d438x26yx3d38x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax26q98k.uizsx3dquiom_$/$/$/$/$/$" + }, + { + "Charles Lee Ray": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f14x2f581261x2fwzqoqvitx2fkpiztma-tmm-zig-c90x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" + }, + { + "Leatherface": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f50x2f9204163x2fwzqoqvitx2ftmibpmznikm-c08x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" + }, + { + "Jack Torrance": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fvwlm_quox2f41x2f9032825x2fwzqoqvitx2friks-bwzzivkm-nqtu-kpizikbmza-x78pwbw-9x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" + }, + { + "Ghostface": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f32x2f9845748x2fwzqoqvitx2fopwabnikm-nqtu-kpizikbmza-x78pwbw-c9x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" + }, + { + "Regan MacNeil": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f73x2f9665843x2fwzqoqvitx2fzmoiv-uikvmqt-zmkwzlqvo-izbqaba-ivl-ozwcx78a-x78pwbw-c1x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" + }, + { + "Hannibal Lecter": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f35x2f9903368x2fwzqoqvitx2fpivvqjit-tmkbmz-nqtu-kpizikbmza-x78pwbw-c99x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" + }, + { + "Samara Morgan": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f77x2f9744082x2fwzqoqvitx2faiuizi-uwzoiv-nqtu-kpizikbmza-x78pwbw-c9x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" + }, + { + "Pinhead": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f79x2f9683232x2fwzqoqvitx2fx78qvpmil-nqtu-kpizikbmza-x78pwbw-c4x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" + }, + { + "Bruce": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f2047x2f63159728x2fwzqoqvitx2friea-c3x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" + }, + { + "Xenomorph": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f909x2f0297919x2fwzqoqvitx2ffmvwuwzx78p-x78pwbw-c91x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" + }, + { + "The Thing": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f1947x2f41148806x2fwzqoqvitx2fbpm-bpqvo-x78pwbw-c6x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" + }, + { + "Sadako Yamamura": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f76x2f9735755x2fwzqoqvitx2failisw-giuiuczi-c4x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" + }, + { + "Jigsaw Killer": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fvwlm_quox2f43x2f9065767x2fwzqoqvitx2frqoaie-sqttmz-nqtu-kpizikbmza-x78pwbw-9x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" + }, + { + "Norman Bates": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f63x2f9467317x2fwzqoqvitx2fvwzuiv-jibma-nqtu-kpizikbmza-x78pwbw-c9x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" + }, + { + "Candyman": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f255x2f7305168x2fwzqoqvitx2fkivlguiv-c5x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" + }, + { + "The Creeper": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fvwlm_quox2f998x2f0963452x2fwzqoqvitx2fbpm-kzmmx78mz-nqtu-kpizikbmza-x78pwbw-9x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" + }, + { + "Damien Thorn": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f29x2f685981x2fwzqoqvitx2fliuqmv-bpwzv-x78pwbw-c1x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" + }, + { + "Joker": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f903x2f0276049x2fwzqoqvitx2frwsmz-c71x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" + }, + { + "Cujo": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f1581x2f52837948x2fwzqoqvitx2fkcrw-nqtu-kpizikbmza-x78pwbw-c9x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" + }, + { + "Gage Creed": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f1968x2f41364699x2fwzqoqvitx2foiom-kzmml-nqtu-kpizikbmza-x78pwbw-c9x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" + }, + { + "Chatterer": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fvwlm_quox2f14x2f586061x2fwzqoqvitx2fkpibbmzmz-nqtu-kpizikbmza-x78pwbw-9x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$" + }, + { + "Pale Man": "https://i2.wp.com/macguff.in/wp-content/uploads/2016/10/Pans-Labyrinth-Movie-Header-Image.jpg?fit=630%2C400&ssl=1" + } + ] +} diff --git a/bot/resources/holidays/halloween/monster.json b/bot/resources/holidays/halloween/monster.json new file mode 100644 index 00000000..5958dc9c --- /dev/null +++ b/bot/resources/holidays/halloween/monster.json @@ -0,0 +1,41 @@ +{ + "monster_type": [ + ["El", "Go", "Ma", "Nya", "Wo", "Hom", "Shar", "Gronn", "Grom", "Blar"], + ["gaf", "mot", "phi", "zyme", "qur", "tile", "pim"], + ["yam", "ja", "rok", "pym", "el"], + ["ya", "tor", "tir", "tyre", "pam"] + ], + "scientist_first_name": ["Ellis", "Elliot", "Rick", "Laurent", "Morgan", "Sophia", "Oak"], + "scientist_last_name": ["E. M.", "E. T.", "Smith", "Schimm", "Schiftner", "Smile", "Tomson", "Thompson", "Huffson", "Argor", "Lephtain", "S. M.", "A. R.", "P. G."], + "verb": [ + "discovered", "created", "found" + ], + "adjective": [ + "ferocious", "spectacular", "incredible", "terrifying" + ], + "physical_adjective": [ + "springy", "rubbery", "bouncy", "tough", "notched", "chipped" + ], + "color": [ + "blue", "green", "teal", "black", "pure white", "obsidian black", "purple", "bright red", "bright yellow" + ], + "attribute": [ + "horns", "teeth", "shell", "fur", "bones", "exoskeleton", "spikes" + ], + "ability": [ + "breathe fire", "devour dreams", "lift thousand-pound weights", "devour metal", "chew up diamonds", "create diamonds", "create gemstones", "breathe icy cold air", "spit poison", "live forever" + ], + "ingredients": [ + "witch's eye", "frog legs", "slime", "true love's kiss", "a lock of golden hair", "the skin of a snake", "a never-melting chunk of ice" + ], + "time": [ + "dusk", "dawn", "mid-day", "midnight on a full moon", "midnight on Halloween night", "the time of a solar eclipse", "the time of a lunar eclipse." + ], + "year": [ + "1996", "1594", "1330", "1700" + ], + "biography_text": [ + {"scientist_first_name": 1, "scientist_last_name": 1, "verb": 1, "adjective": 1, "attribute": 1, "ability": 1, "color": 1, "year": 1, "time": 1, "physical_adjective": 1, "text": "Your name is {monster_name}, a member of the {adjective} species {monster_species}. The first {monster_species} was {verb} by {scientist_first_name} {scientist_last_name} in {year} at {time}. The species {monster_species} is known for its {physical_adjective} {color} {attribute}. It is said to even be able to {ability}!"}, + {"scientist_first_name": 1, "scientist_last_name": 1, "adjective": 1, "attribute": 1, "physical_adjective": 1, "ingredients": 2, "time": 1, "ability": 1, "verb": 1, "color": 1, "year": 1, "text": "The {monster_species} is an {adjective} species, and you, {monster_name}, are no exception. {monster_species} is famed for its {physical_adjective} {attribute}. Whispers say that when brewed with {ingredients[0]} and {ingredients[1]} at {time}, a foul, {color} brew will be produced, granting it's drinker the ability to {ability}! This species was {verb} by {scientist_first_name} {scientist_last_name} in {year}."} + ] +} diff --git a/bot/resources/holidays/halloween/monstersurvey.json b/bot/resources/holidays/halloween/monstersurvey.json new file mode 100644 index 00000000..d8cc72e7 --- /dev/null +++ b/bot/resources/holidays/halloween/monstersurvey.json @@ -0,0 +1,28 @@ +{ + "frankenstein": { + "full_name": "Frankenstein's Monster", + "summary": "His limbs were in proportion, and I had selected his features as beautiful. Beautiful! Great God! His yellow skin scarcely covered the work of muscles and arteries beneath; his hair was of a lustrous black, and flowing; his teeth of a pearly whiteness; but these luxuriances only formed a more horrid contrast with his watery eyes, that seemed almost of the same colour as the dun-white sockets in which they were set, his shrivelled complexion and straight black lips.", + "image": "https://upload.wikimedia.org/wikipedia/commons/a/a7/Frankenstein%27s_monster_%28Boris_Karloff%29.jpg", + "votes": [] + }, + "dracula": { + "full_name": "Count Dracula", + "summary": "Count Dracula is an undead, centuries-old vampire, and a Transylvanian nobleman who claims to be a Sz\u00c3\u00a9kely descended from Attila the Hun. He inhabits a decaying castle in the Carpathian Mountains near the Borgo Pass. Unlike the vampires of Eastern European folklore, which are portrayed as repulsive, corpse-like creatures, Dracula wears a veneer of aristocratic charm. In his conversations with Jonathan Harker, he reveals himself as deeply proud of his boyar heritage and nostalgic for the past, which he admits have become only a memory of heroism, honour and valour in modern times.", + "image": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/90/Bela_Lugosi_as_Dracula%2C_anonymous_photograph_from_1931%2C_Universal_Studios.jpg/250px-Bela_Lugosi_as_Dracula%2C_anonymous_photograph_from_1931%2C_Universal_Studios.jpg", + "votes": [ + ] + }, + "goofy": { + "full_name": "Goofy in the Monster's INC World", + "summary": "Pure nightmare fuel.\nThis monster is nothing like its original counterpart. With two different eyes, a pointed nose, fins growing out of its blue skin, and dark spots covering his body, he's a true nightmare come to life.", + "image": "https://www.dailydot.com/wp-content/uploads/3a2/a8/bf38aedbef9f795f.png", + "votes": [] + }, + "refisio": { + "full_name": "Refisio", + "summary": "Who let this guy write this? That's who the real monster is.", + "image": "https://avatars0.githubusercontent.com/u/24819750?s=460&v=4", + "votes": [ + ] + } +} diff --git a/bot/resources/holidays/halloween/responses.json b/bot/resources/holidays/halloween/responses.json new file mode 100644 index 00000000..c0f24c1a --- /dev/null +++ b/bot/resources/holidays/halloween/responses.json @@ -0,0 +1,14 @@ +{ + "responses": [ + ["No."], + ["Yes."], + ["I will seek and answer from the devil...", "...after requesting the devils knowledge the answer is far more complicated than a simple yes or no."], + ["This knowledge is not available to me, I will seek the answers from someone far more powerful...", "...there is no answer to this question, not even the Grim Reaper could find an answer."], + ["The ghosts I summoned have confirmed that is is certain."], + ["Double, double, toil and trouble,\nFire burn and cauldron bubble.\nCool it with a baboon's blood,\nand tell me the answer to his question...", "...the great cauldron can only confirm your beliefs."], + ["Double, double, toil and trouble,\nFire burn and cauldron bubble.\nCool it with a baboon's blood,\nand tell me the answer to his question...", "...the great cauldron can only confirm that the answer to your question is no."], + ["If I tell you I will have to kill you..."], + ["I swear on my spider that you are correct."], + ["With great certainty, under the watch of the Pumpkin King, I can confirm your suspicions."], + ["The undead have sworn me to secrecy. I can not answer your question."] +]} diff --git a/bot/resources/holidays/halloween/spooky_rating.json b/bot/resources/holidays/halloween/spooky_rating.json new file mode 100644 index 00000000..8e3e66bb --- /dev/null +++ b/bot/resources/holidays/halloween/spooky_rating.json @@ -0,0 +1,47 @@ +{ + "-1": { + "title": "\uD83D\uDD6F You're not scarin' anyone \uD83D\uDD6F", + "text": "No matter what you say or do, nobody even flinches when you try to scare them. Was your costume this year only a white sheet with holes for eyes? Or did you even bother with a costume at all? Either way, don't expect too many treats when going from door-to-door.", + "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/halloween/spookyrating/candle.jpeg" + }, + "5": { + "title": "\uD83D\uDC76 Like taking candy from a baby \uD83D\uDC76", + "text": "Your scaring will probably make a baby cry... but that's the limit on your frightening powers. Be careful not to get to the point where everyone's running away from you because they don't like you, not because they're scared of you.", + "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/halloween/spookyrating/baby.jpeg" + }, + "20": { + "title": "\uD83C\uDFDA You're skills are forming... \uD83C\uDFDA", + "text": "As you become the Devil's apprentice, you begin to make people jump every time you sneak up on them. A good start, but you have to learn not to wear the same costume every year until it doesn't fit you. People will notice you and your prowess will decrease.", + "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/halloween/spookyrating/tiger.jpeg" + }, + "30": { + "title": "\uD83D\uDC80 Picture Perfect... \uD83D\uDC80", + "text": "You've nailed the costume this year! You look suuuper scary! Now make sure to play the part and act out your costume and you'll be sure to give a few people a massive fright!", + "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/halloween/spookyrating/costume.jpeg" + }, + "50": { + "title": "\uD83D\uDC7B Uhm... are you human \uD83D\uDC7B", + "text": "Uhm... you're too good to be human and now you're beginning to sound like a ghost. You're almost invisible when haunting and nobody truly knows where you are at any given time. But they will always scream at the sound of a ghost...", + "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/halloween/spookyrating/ghost.jpeg" + }, + "65": { + "title": "\uD83C\uDF83 That potion can't be real \uD83C\uDF83", + "text": "You're carrying... some... unknown liquids and no one knows who they are but yourself. Be careful on who you use these powerful spells on, because no Mage has the power to do any irreversible enchantments because even you won't know what will happen to these mortals.", + "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/halloween/spookyrating/necromancer.jepg" + }, + "80": { + "title": "\uD83E\uDD21 The most sinister face \uD83E\uDD21", + "text": "Who knew something intended to be playful could be so menacing... Especially other people seeing you in their nightmares, continuing to haunt them day by day, stuck in their head throughout the entire year. Make sure to pull a face they will never forget.", + "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/halloween/spookyrating/clown.jpeg" + }, + "95": { + "title": "\uD83D\uDE08 The Devil's Accomplice \uD83D\uDE08", + "text": "Imagine being allies with the most evil character with an aim to scare people to death. Force people to suffer as they proceed straight to hell to meet your boss and best friend. Not even you know the power He has...", + "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/halloween/spookyrating/jackolantern.jpg" + }, + "100": { + "title":"\uD83D\uDC7F The Devil Himself \uD83D\uDC7F", + "text": "You are the evillest creature in existence to scare anyone and everyone humanly possible. The reason your underlings are called mortals is that they die. With your help, they die a lot quicker. With all the evil power in the universe, you know what to do.", + "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/halloween/spookyrating/devil.jpeg" + } +} diff --git a/bot/resources/holidays/halloween/spookynamerate_names.json b/bot/resources/holidays/halloween/spookynamerate_names.json new file mode 100644 index 00000000..7657880b --- /dev/null +++ b/bot/resources/holidays/halloween/spookynamerate_names.json @@ -0,0 +1,2206 @@ +{ + "first_names": [ + "Eberhard", + "Gladys", + "Joshua", + "Misty", + "Bondy", + "Constantine", + "Juliette", + "Dalis", + "Nap", + "Sandy", + "Inglebert", + "Sasha", + "Julietta", + "Christoforo", + "Del", + "Zelma", + "Vladimir", + "Wayland", + "Enos", + "Siobhan", + "Farrand", + "Ailee", + "Horatia", + "Gloriana", + "Britney", + "Shel", + "Lindsey", + "Francis", + "Elsa", + "Fred", + "Upton", + "Lothaire", + "Cara", + "Margarete", + "Wolfgang", + "Charin", + "Loydie", + "Aurelea", + "Sibel", + "Glenden", + "Julian", + "Roby", + "Gerri", + "Sandie", + "Twila", + "Shaylyn", + "Clyde", + "Dina", + "Chase", + "Caron", + "Carlin", + "Aida", + "Rhonda", + "Rebekkah", + "Charmian", + "Lindy", + "Obadiah", + "Willy", + "Matti", + "Melodie", + "Ira", + "Wilfrid", + "Berton", + "Denver", + "Clarette", + "Nicolas", + "Tawnya", + "Cynthy", + "Arman", + "Sherwood", + "Flemming", + "Berri", + "Beret", + "Aili", + "Hannie", + "Eadie", + "Tannie", + "Gilda", + "Walton", + "Nolly", + "Tonya", + "Meaghan", + "Timmi", + "Faina", + "Sarge", + "Britteny", + "Farlay", + "Carola", + "Skippy", + "Corrina", + "Hans", + "Courtnay", + "Taffy", + "Averill", + "Martie", + "Tobye", + "Broderic", + "Gardner", + "Lucky", + "Beverie", + "Ignaz", + "Siana", + "Marybelle", + "Leif", + "Baily", + "Pyotr", + "Myrtle", + "Darb", + "Gar", + "Vinni", + "Samson", + "Kinny", + "Briant", + "Verney", + "Del", + "Marion", + "Beniamino", + "Nona", + "Fay", + "Noreen", + "Maurizio", + "Nial", + "Mirabella", + "Melisa", + "Anatol", + "Halette", + "Johnathon", + "Antonietta", + "Germana", + "Towny", + "Shayne", + "Court", + "Merrile", + "Staffard", + "Odele", + "Gustav", + "Moyna", + "Warden", + "Craggie", + "Hurleigh", + "Hartley", + "Rustie", + "Raven", + "Farra", + "Leonidas", + "Jorrie", + "Maximilian", + "Augustin", + "Cordelia", + "Christoffer", + "Lana", + "Vittorio", + "Janith", + "Margaret", + "Bethanne", + "Brooke", + "Payton", + "Poul", + "Diahann", + "Andy", + "Garek", + "Isa", + "Dunn", + "Anny", + "Hillary", + "Andres", + "Winn", + "Gare", + "Ameline", + "Audre", + "Rodrigo", + "Anabal", + "Reuben", + "Cecil", + "Alexandro", + "Corny", + "Erek", + "William", + "Rudyard", + "Muffin", + "Allin", + "Emmit", + "Heindrick", + "Myrna", + "Kriste", + "Perry", + "Annmarie", + "Jasun", + "Esdras", + "Jobyna", + "Marian", + "Theodore", + "Dionisio", + "Efren", + "Clarita", + "Leilah", + "Modestia", + "Clem", + "Jemmy", + "Karol", + "Minni", + "Damien", + "Tessy", + "Roanne", + "Daniele", + "Camel", + "Charlot", + "Daron", + "Cherey", + "Ashil", + "Joel", + "Michell", + "Sukey", + "Micheil", + "Chev", + "Winny", + "Natale", + "Kendra", + "Bell", + "Darice", + "Beilul", + "Leonore", + "Abba", + "Warden", + "Bryna", + "Sammy", + "Brantley", + "Goldi", + "Meridith", + "Eleanor", + "Brear", + "Kristina", + "Muriel", + "Serge", + "Iver", + "Jonis", + "Ada", + "Marleen", + "Pavlov", + "Kellia", + "Abdel", + "Waylin", + "Ignazio", + "Tana", + "Kiley", + "Lynna", + "Peyton", + "Linoel", + "Patrice", + "Loria", + "Linda", + "Edna", + "Viki", + "Kelcy", + "Chelsae", + "Olga", + "Trace", + "Ethel", + "Giorgio", + "Geralda", + "Rosaline", + "Caralie", + "Duke", + "Sig", + "Seana", + "Boris", + "Jeanie", + "Stacee", + "Giffie", + "Myrta", + "Prescott", + "Roger", + "Ame", + "Lelia", + "Marthena", + "Mord", + "Tommi", + "Artemus", + "Wynn", + "Rodi", + "Denna", + "Joleen", + "Iris", + "Pascale", + "Cody", + "Kienan", + "Darline", + "Lanna", + "Chandra", + "Michel", + "Nanete", + "Rosana", + "Ondrea", + "Linette", + "Syd", + "Rhianon", + "Christiano", + "Moyna", + "Darbee", + "Chadd", + "Roselia", + "Niki", + "Flint", + "Natala", + "Merrie", + "Noelyn", + "Arvin", + "Vin", + "Khalil", + "Nance", + "Seward", + "Dagmar", + "Shanta", + "Noland", + "Vance", + "Kyla", + "Locke", + "Abagail", + "Guthrey", + "Thalia", + "Devlen", + "Parrnell", + "Leonard", + "Amber", + "Dell", + "Lolita", + "Revkah", + "Ronna", + "Ninnetta", + "Jobey", + "Larisa", + "Wendel", + "Sonnnie", + "Saul", + "Lem", + "Wang", + "Borg", + "Korie", + "Rosanna", + "Barnaby", + "Channa", + "Gordan", + "Wang", + "Dasi", + "Laurianne", + "Jo ann", + "Bond", + "Kean", + "Harwell", + "Abbey", + "Carlo", + "Hamil", + "Ameline", + "Tristam", + "Donn", + "Earle", + "Lanie", + "Maximilianus", + "Frieda", + "Noella", + "Orsa", + "Timmi", + "Linea", + "Claudina", + "Langsdon", + "Murdock", + "Cello", + "Lek", + "Viviyan", + "Candra", + "Erena", + "Shirline", + "Mariann", + "Keelby", + "Jacquelin", + "Clerissa", + "Davis", + "Ara", + "My", + "Andris", + "Drugi", + "Lynn", + "Andonis", + "Jamie", + "Cherise", + "Lonni", + "Reamonn", + "Cathee", + "Clarence", + "Joletta", + "Tanny", + "Gasparo", + "Heddie", + "Cullin", + "Sander", + "Emmalee", + "Gwendolin", + "Hayley", + "Mandie", + "Cassondra", + "Celestyna", + "Fanny", + "Alica", + "Vivyan", + "Kippy", + "Leandra", + "Jerry", + "Elspeth", + "Lexine", + "Tobie", + "Allin", + "Ambros", + "Ash", + "Conroy", + "Melonie", + "Aylmer", + "Maximo", + "Connie", + "Torre", + "Tammie", + "Corabella", + "Beau", + "Nancee", + "Ailbert", + "Florrie", + "Trevar", + "Tiffani", + "Dre", + "Eward", + "Hallie", + "Stesha", + "Ralina", + "Vinni", + "Bastien", + "Galvan", + "Romain", + "Yasmin", + "Theodoric", + "Maxy", + "Lesly", + "Gerald", + "Erskine", + "Joice", + "Theadora", + "Sheeree", + "Danit", + "Burr", + "Morten", + "Godfree", + "Lacey", + "Sandye", + "Louisa", + "Annora", + "Rochester", + "Saundra", + "Deeann", + "Aloisia", + "Oralle", + "Ree", + "Kaile", + "Rogerio", + "Graeme", + "Garald", + "Hulda", + "Deny", + "Bessy", + "Zarah", + "Melisande", + "Taffy", + "Jed", + "Bar", + "Jacki", + "Avictor", + "Damiano", + "Yasmeen", + "Geralda", + "Kermie", + "Verge", + "Cyril", + "Klara", + "Anna", + "Abey", + "Mariellen", + "Mirabel", + "Charmain", + "Carleton", + "Biddie", + "Junina", + "Cass", + "Jdavie", + "Laird", + "Olenka", + "Dion", + "Hedy", + "Haley", + "Stacy", + "Alis", + "Morena", + "Damita", + "Wynn", + "Kellia", + "Midge", + "Gerri", + "Symon", + "Markus", + "Brenn", + "Rancell", + "Marlon", + "Dulciana", + "Lemmy", + "Neale", + "Vladamir", + "Alasteir", + "Gilberta", + "Seumas", + "Ronda", + "Myrvyn", + "Gabey", + "Goldia", + "Lothaire", + "Averil", + "Marlo", + "Nanice", + "Bernadette", + "Nehemiah", + "Ivar", + "Natala", + "Dorthy", + "Melva", + "Alisha", + "Ruthann", + "Ray", + "Ariel", + "Gib", + "Pippo", + "Miner", + "Ardith", + "Letisha", + "Granger", + "Sue", + "Toby", + "Tallou", + "Stephi", + "Hunter", + "Terrell", + "Pail", + "Moise", + "Rosetta", + "Ira", + "Denyse", + "Jackie", + "Fons", + "Goldy", + "Rani", + "Bendick", + "Valentijn", + "Annabell", + "Ardith", + "Lesly", + "Almire", + "Emmalyn", + "Mechelle", + "Anna", + "Duff", + "Louise", + "Vivian", + "Farand", + "Sophi", + "Thedric", + "Vivien", + "Jere", + "Kassie", + "Andy", + "Helli", + "Ros", + "Babara", + "Othella", + "Shelton", + "Hector", + "Charmian", + "Rosamond", + "Maison", + "Magda", + "Gustave", + "Latisha", + "Erik", + "Gavin", + "Bobette", + "Masha", + "Collie", + "Kippie", + "Jillayne", + "Fairfax", + "Ulrika", + "Juliann", + "Joly", + "Aldus", + "Clarie", + "Aluin", + "Claudetta", + "Noella", + "Nichols", + "Rutger", + "Niall", + "Hunter", + "Hyacinthia", + "Eva", + "Humphrey", + "Randi", + "Leontyne", + "Bordy", + "Orin", + "Tobey", + "Aldis", + "Vernon", + "Griz", + "Dynah", + "Ann-marie", + "Inglebert", + "Gifford", + "Emeline", + "Shem", + "Sigvard", + "Mayne", + "Rhodia", + "Seward", + "Valencia", + "Babara", + "Cirstoforo", + "Nye", + "Merissa", + "Lucinda", + "Wynn", + "Vassili", + "Cletus", + "Felisha", + "Laural", + "William", + "Emmalynne", + "Angy", + "Charles", + "Jemmy", + "Edward", + "Millicent", + "Homer", + "Allie", + "Brandyn", + "Dannye", + "Hector", + "Fawne", + "Frayda", + "Issiah", + "Deana", + "Bearnard", + "Ken", + "Sinclare", + "Mallorie", + "Noby", + "Deonne", + "Brig", + "Ruy", + "Vivia", + "Nyssa", + "Ame", + "Carmen", + "Solly", + "Carolee", + "Felice", + "Claiborne", + "Layney", + "Raina", + "Tami", + "Dosi", + "Barth", + "Julita", + "Gardiner", + "Stesha", + "Geneva", + "Saudra", + "Ella", + "Welbie", + "Marya", + "Happy", + "Brandise", + "Jewell", + "Joana", + "Eddy", + "Buck", + "Leslie", + "Yolanda", + "Murdoch", + "Muffin", + "Myrna", + "Susi", + "Berthe", + "Debra", + "Kyla", + "Bron", + "Thurston", + "Case", + "Shelli", + "Danika", + "Charissa", + "Wylie", + "Corine", + "Caitrin", + "Atalanta", + "Vevay", + "Thekla", + "Inez", + "Pris", + "Zsazsa", + "Ardenia", + "Ole", + "Kelcy", + "Earl", + "Pierson", + "Opalina", + "Leta", + "Keefer", + "Conrado", + "Chen", + "Alys", + "Floyd", + "Kai", + "Warden", + "Peyton", + "Debora", + "Walton", + "Fionna", + "Kendra", + "Michail", + "Christa", + "Theodor", + "Avivah", + "Patric", + "Quinton", + "Fey", + "Lewiss", + "Loren", + "Nedi", + "Fergus", + "Jeanie", + "Liuka", + "Ashley", + "Ellsworth", + "Winslow", + "Land", + "Rooney", + "Kati", + "Joelie", + "Garner", + "Clarice", + "Clair", + "Heddi", + "Ivan", + "Enrichetta", + "Umberto", + "Alys", + "Marcellina", + "Elnore", + "Wilburt", + "Ami", + "Meridith", + "Devlin", + "Cicely", + "Nathanael", + "Rafi", + "Arluene", + "Erasmus", + "Tasia", + "Seumas", + "George", + "Fredrika", + "Jayne", + "Linus", + "Mathilde", + "Klarrisa", + "Willy", + "Rad", + "Rae", + "Wilfred", + "Amberly", + "Paulo", + "Robbi", + "Gladys", + "Mirilla", + "Danica", + "Montgomery", + "Bellina", + "Neill", + "Roddie", + "Sebastiano", + "Adrianne", + "Gilli", + "Rhodia", + "Orbadiah", + "Levy", + "Griswold", + "Millicent", + "Carry", + "Alexander", + "Carole", + "Othilie", + "Enrica", + "Corissa", + "Meaghan", + "Margret", + "Sheff", + "Walton", + "Tremain", + "Bear", + "Maximilian", + "Theodora", + "Fredric", + "Baudoin", + "Rees", + "Roldan", + "Mayor", + "Angelica", + "Clemente", + "Florencia", + "Lancelot", + "Valencia", + "Caddric", + "Frieda", + "Jarvis", + "Shamus", + "Kalindi", + "Allen", + "Maureen", + "Ax", + "Barbra", + "Craggy", + "Howie", + "Orson", + "Cammy", + "Sullivan", + "Marleen", + "Jarrad", + "Lucy", + "Catha", + "Guillemette", + "Birdie", + "Forrest", + "Luce", + "Myriam", + "Serge", + "Kali", + "Ruperto", + "Trisha", + "Shaylynn", + "Janella", + "Franciskus", + "Melinde", + "Effie", + "Letti", + "Roderic", + "Jandy", + "Michaelina", + "Mohammed", + "Dolorita", + "Elbertine", + "Esma", + "Emmett", + "Lucila", + "Joyann", + "Mufi", + "Karlotta", + "Vannie", + "Daphna", + "Blondie", + "Madelene", + "Tomkin", + "Kassie", + "Flynn", + "Zebadiah", + "Lauritz", + "Brian", + "Leah", + "Amalita", + "Corissa", + "Onfre", + "Shantee", + "Deena", + "Marena", + "Alejoa", + "Fania", + "Catha", + "Cherlyn", + "Gerrilee", + "Brook", + "Yardley", + "Karry", + "Dennis", + "Ingra", + "Damian", + "Alexandros", + "Romola", + "Grantley", + "Antons", + "Randal", + "Lorilee", + "Brier", + "Tyrone", + "Jennica", + "Deidre", + "Arlin", + "Marline", + "Lyell", + "Lorelei", + "Marius", + "Willy", + "Teddy", + "Grantham", + "Yelena", + "Jaimie", + "Brewer", + "Tess", + "Othelia", + "Bondy", + "Rebecka", + "Laurice", + "Jasen", + "Betty", + "Alverta", + "Pepita", + "Kandace", + "Loni", + "Doreen", + "Ketty", + "Ree", + "Danni", + "Zorah", + "Shayla", + "Ivy", + "Darin", + "Karie", + "Brittaney", + "Viole", + "Harlene", + "Jasun", + "Aime", + "Rickie", + "Heath", + "Andris", + "Vaughn", + "Giorgi", + "Maddalena", + "Shirley", + "Cherie", + "Zacharia", + "Darcey", + "Barbee", + "Ernest", + "Sher", + "Faustina", + "Nari", + "Gusella", + "Reginald", + "Zack", + "Michele", + "Gene", + "Lindy", + "Mirilla", + "Tudor", + "Tyler", + "Bernadina", + "Magdalen", + "Nollie", + "Coreen", + "Hoebart", + "Virginie", + "Waylin", + "Hank", + "Valenka", + "Sabine", + "Jesus", + "Annabell", + "Jesselyn", + "Marysa", + "Corbett", + "Carena", + "Bert", + "Tanhya", + "Alphonse", + "Johnette", + "Vince", + "Cordell", + "Ramonda", + "Trev", + "Glenna", + "Loy", + "Arni", + "Tedd", + "Tristam", + "Zelma", + "Emmeline", + "Ellswerth", + "Janeta", + "Hughie", + "Tarun", + "Enid", + "Rafe", + "Hal", + "Melissa", + "Layan", + "Sia", + "Horace", + "Derry", + "Kelsi", + "Zacharia", + "Tillie", + "Dillon", + "Maxwell", + "Shanai", + "Charlize", + "Usama", + "Nabeela", + "Emily-Jane", + "Martyn", + "Tre", + "Ioan", + "Elysia", + "Mikaeel", + "Danny", + "Ciaron", + "Ace", + "Amy-Louise", + "Gabrielle", + "Robbie", + "Thea", + "Gloria", + "Jana", + "Cole", + "Eamon", + "Samiyah", + "Ellie-Mai", + "Lawson", + "Gia", + "Merryn", + "Andre", + "Ansh", + "Kavita", + "Alasdair", + "Aamina", + "Donna", + "Dario", + "Sahra", + "Brittany", + "Shakeel", + "Taylor", + "Ellenor", + "Kacy", + "Gene", + "Hetty", + "Fletcher", + "Donte", + "Krisha", + "Everett", + "Leila", + "Aairah", + "Zander", + "Sakina", + "Sanaya", + "Nelly", + "Manon", + "Antonio", + "Aimie", + "Kyran", + "Daria", + "Tilly-Mae", + "Lisa", + "Ammaarah", + "Adina", + "Kaan", + "Torin", + "Sadie", + "Mia-Rose", + "Aadam", + "Phyllis", + "Jace", + "Fraser", + "Tamanna", + "Dahlia", + "Cristian", + "Maira", + "Lana", + "Lily-Mai", + "Barney", + "Beatrice", + "Tabitha", + "Anis", + "Heidi", + "Ahyan", + "Usaamah", + "Jolene", + "Melisa", + "Magdalena", + "Hina" + ], + "last_names": [ + "Silveston", + "Manson", + "Hoodlass", + "Auden", + "Speakman", + "Seavers", + "Sodeau", + "Gouth", + "Pickersail", + "Ferschke", + "Buzzing", + "Kinnar", + "Pemberton", + "Firebrace", + "Kornilyev", + "Linsley", + "Petyanin", + "McCobb", + "Disdel", + "Eskrick", + "Pringuer", + "Clavering", + "Sims", + "Lippitt", + "Springall", + "Spiteri", + "Dwyr", + "Tomas", + "Cleminson", + "Crowder", + "Juster", + "Leven", + "Doucette", + "Schimoni", + "Readwing", + "Karet", + "Reef", + "Welden", + "Bemand", + "Schulze", + "Bartul", + "Collihole", + "Thain", + "Bernhardt", + "Tolputt", + "Hedges", + "Lowne", + "Kobu", + "Cabrera", + "Gavozzi", + "Ghilardini", + "Leamon", + "Gadsden", + "Gregg", + "Tew", + "Bangle", + "Youster", + "Vince", + "Cristea", + "Ablott", + "Lightowlers", + "Kittredge", + "Armour", + "Bukowski", + "Knowlton", + "Juett", + "Santorini", + "Ends", + "Hawkings", + "Janowicz", + "Harry", + "Bougourd", + "Gillow", + "Whalebelly", + "Conneau", + "Mellows", + "Stolting", + "Stickells", + "Maryet", + "Echallie", + "Edgecombe", + "Orchart", + "Mowles", + "McGibbon", + "Titchen", + "Madgewick", + "Fairburne", + "Colgan", + "Chaudhry", + "Taks", + "Lorinez", + "Eixenberger", + "Burel", + "Chapleo", + "Margram", + "Purse", + "MacKay", + "Oxlade", + "Prahm", + "Wellbank", + "Blackborow", + "Woodbridge", + "Sodory", + "Vedmore", + "Beeckx", + "Newcomb", + "Ridel", + "Desporte", + "Jobling", + "Winear", + "Korneichuk", + "Aucott", + "Wawer", + "Aicheson", + "Hawkslee", + "Wynes", + "St. Quentin", + "McQuorkel", + "Hendrick", + "Rudsdale", + "Winsor", + "Thunders", + "Stonbridge", + "Perrie", + "D'Alessandro", + "Banasevich", + "Mc Elory", + "Cobbledick", + "Wreakes", + "Carnie", + "Pallister", + "Yeates", + "Hoovart", + "Doogood", + "Churn", + "Gillon", + "Nibley", + "Dusting", + "Melledy", + "O'Noland", + "Crosfeld", + "Pairpoint", + "Longson", + "Rodden", + "Foyston", + "Le Teve", + "Brumen", + "Pudsey", + "Klimentov", + "Agent", + "Seabert", + "Cramp", + "Bitcheno", + "Embery", + "Etheredge", + "Sheardown", + "McKune", + "Vearncomb", + "Lavington", + "Rylands", + "Derges", + "Olivetti", + "Matasov", + "Thrower", + "Jobin", + "Ramsell", + "Rude", + "Tregale", + "Bradforth", + "McQuarter", + "Walburn", + "Poad", + "Filtness", + "Carneck", + "Pavis", + "Pinchen", + "Polye", + "Abry", + "Radloff", + "McDugal", + "Loughton", + "Revitt", + "Baniard", + "Kovalski", + "Mapother", + "Hendrikse", + "Rickardsson", + "Featherbie", + "Harlow", + "Kruschov", + "McCrillis", + "Barabich", + "Peaker", + "Skamell", + "Gorges", + "Chance", + "Bresner", + "Profit", + "Swinfon", + "Goldson", + "Nunson", + "Tarling", + "Ruperti", + "Grimsell", + "Davey", + "Deetlof", + "Gave", + "Fawltey", + "Tyre", + "Whaymand", + "Trudgian", + "McAndrew", + "Aleksankov", + "Dimbleby", + "Beseke", + "Cleverley", + "Aberhart", + "Courtin", + "MacKellen", + "Johannesson", + "Churm", + "Laverock", + "Astbury", + "Canto", + "Nelles", + "Dormand", + "Blucher", + "Youngs", + "Dalrymple", + "M'Chirrie", + "Jansens", + "Golthorpp", + "Ibberson", + "Andriveau", + "Paulton", + "Parrington", + "Shergill", + "Bickerton", + "Hugonneau", + "Cornelissen", + "Spincks", + "Malkinson", + "Kettow", + "Wasiel", + "Skeat", + "Maynard", + "Goutcher", + "Cratchley", + "Loving", + "Averies", + "Cahillane", + "Alvarado", + "Truggian", + "Bravington", + "McGonigle", + "Crocombe", + "Slorance", + "Dukes", + "Nairns", + "Condict", + "Got", + "Flowerdew", + "Deboy", + "Death", + "Patroni", + "Colgrave", + "Polley", + "Spraging", + "Orteaux", + "Daskiewicz", + "Dunsmore", + "Forrington", + "De Gogay", + "Swires", + "Grimmert", + "Castells", + "Scraggs", + "Chase", + "Dixsee", + "Brennans", + "Gookes", + "MacQueen", + "Galbreth", + "Buttwell", + "Annear", + "Sutherley", + "Portis", + "Pashen", + "Blackbourn", + "Sedgemond", + "Huegett", + "Emms", + "Leifer", + "Paschek", + "Bynold", + "Mahony", + "Izacenko", + "Hadland", + "Sallows", + "Hamper", + "Godlee", + "Rablin", + "Emms", + "Zealy", + "Russi", + "Crassweller", + "Shotbolt", + "Van Der Weedenburg", + "MacGille", + "Carillo", + "Guerin", + "Cuolahan", + "Metzel", + "Martinovsky", + "Stoggles", + "Brameld", + "Coupland", + "Kaaskooper", + "Sallows", + "Rizzotto", + "Dike", + "O'Lochan", + "Spragg", + "Lavarack", + "MacNess", + "Swetenham", + "Dillet", + "Coffey", + "Meikle", + "Loynes", + "Josum", + "Adkin", + "Tompsett", + "Maclaine", + "Fippe", + "Bispo", + "Whittek", + "Rylett", + "Iveagh", + "Elgar", + "Casswell", + "Tilt", + "Macklin", + "Lillee", + "Hamshere", + "Coite", + "Dollard", + "Tiesman", + "Coltart", + "Stothert", + "Crosswaite", + "Padgett", + "Gleadle", + "Meedendorpe", + "Alexsandrovich", + "Williamson", + "Futty", + "Antwis", + "Romanski", + "Dionisetti", + "Dimitriev", + "Swalowe", + "Dewing", + "O'Driscoll", + "Jeandel", + "Summerly", + "Shoute", + "Trelevan", + "Matkin", + "Headey", + "Rosson", + "Dunn", + "Gunner", + "Stapells", + "Fratczak", + "McGillivray", + "Edis", + "Treuge", + "Haskayne", + "Perell", + "O'Fairy", + "Slisby", + "Axcell", + "Mattingley", + "Tumilty", + "Kibble", + "Lambert", + "Hassall", + "Simpkin", + "Nitti", + "Stiegar", + "Pavitt", + "Kerby", + "Ruzic", + "Westwick", + "Tonbye", + "Bocken", + "Kinforth", + "Wren", + "Attow", + "McComish", + "McNickle", + "Wildman", + "O'Corhane", + "Jewar", + "Caveau", + "Woodrooffe", + "Batson", + "Stayt", + "A'field", + "Domesday", + "Taberer", + "Gigg", + "Stanmore", + "Hanton", + "Roskell", + "Brasener", + "Stanbro", + "Cordy", + "O'Bradane", + "Hansberry", + "Erdes", + "Wagon", + "Jimmes", + "Ruffles", + "Wigginton", + "Haste", + "Rymill", + "Tomsett", + "Ambrosoli", + "Reidshaw", + "Nurcombe", + "Costigan", + "Berwick", + "Hinchon", + "Blissitt", + "Golston", + "Goullee", + "Hudspeth", + "Traher", + "Salandino", + "Fatscher", + "Davidov", + "Baukham", + "Mallan", + "Kilmurray", + "Dmych", + "Mair", + "Felmingham", + "Kedward", + "Leechman", + "Frank", + "Tremoulet", + "Manley", + "Newcom", + "Brandone", + "Cliffe", + "Shorte", + "Baalham", + "Fairhead", + "Sheal", + "Effnert", + "MacCaughey", + "Rizzolo", + "Linthead", + "Greenhouse", + "Clayson", + "Franca", + "Lambell", + "Egdal", + "Pringell", + "Penni", + "Train", + "Langfitt", + "Dady", + "Rannigan", + "Ledwidge", + "Summerton", + "D'Hooghe", + "Ary", + "Gooderick", + "Scarsbrooke", + "Janouch", + "Pond", + "Menichini", + "Crinidge", + "Sneesbie", + "Harflete", + "Ubsdell", + "Littleover", + "Vanne", + "Fassbender", + "Zellner", + "Gorce", + "McKeighan", + "Claffey", + "MacGarvey", + "Norwich", + "Antosch", + "Loughton", + "McCuthais", + "Arnaudi", + "Broz", + "Stert", + "McMechan", + "Texton", + "Bees", + "Couser", + "Easseby", + "McCorry", + "Fetterplace", + "Crankshaw", + "Spancock", + "Neasam", + "Bruckental", + "Badgers", + "Rodda", + "Bossingham", + "Crump", + "Jurgensen", + "Noyes", + "Scarman", + "Bakey", + "Swindin", + "Tolworthie", + "Vynehall", + "Shallcrass", + "Bazoge", + "Jonczyk", + "Eatherton", + "Finlason", + "Hembery", + "Lassetter", + "Soule", + "Baldocci", + "Thurman", + "Poppy", + "Eveque", + "Summerlad", + "Eberle", + "Pettecrew", + "Hitzmann", + "Allonby", + "Bodimeade", + "Catteroll", + "Wooldridge", + "Baines", + "Halloway", + "Doghartie", + "Bracher", + "Kynnd", + "Metherell", + "Routham", + "Fielder", + "Ashleigh", + "Aked", + "Kolakowski", + "Picardo", + "Murdy", + "Feacham", + "Lewin", + "Braben", + "Salaman", + "Letterick", + "Bovaird", + "Moriarty", + "Bertot", + "Cowan", + "Dionisi", + "Maybey", + "Joskowicz", + "Shoutt", + "Bernli", + "Dikles", + "Corringham", + "Shaw", + "Donovin", + "Merigeau", + "Pinckney", + "Queripel", + "Sampson", + "Benfell", + "Cansdell", + "Tasseler", + "Amthor", + "Nancekivell", + "Stock", + "Boltwood", + "Goreisr", + "Le Grand", + "Terrans", + "Knapp", + "Roseman", + "Gunstone", + "Hissie", + "Orto", + "Bell", + "Colam", + "Drust", + "Roseblade", + "Sulman", + "Jennaway", + "Joust", + "Curthoys", + "Cajkler", + "MacIllrick", + "Print", + "Coulthard", + "Lemmon", + "Bush", + "McMurrugh", + "Toping", + "Brute", + "Fryman", + "Bosomworth", + "Lawson", + "Lauder", + "Heinssen", + "Bittlestone", + "Brinson", + "Hambling", + "Vassman", + "Brookbank", + "Bolstridge", + "Leslie", + "Berndsen", + "Aindrais", + "Mogra", + "Wilson", + "Josefs", + "Norgan", + "Wong", + "le Keux", + "Hastwall", + "Bunson", + "Van", + "Waghorne", + "Ojeda", + "Boole", + "Winters", + "Gurge", + "Gallemore", + "Perulli", + "Dight", + "Di Filippo", + "Winsley", + "Chalcraft", + "Human", + "Laetham", + "Lennie", + "McSorley", + "Toolan", + "Brammar", + "Cadogan", + "Molloy", + "Shoveller", + "Vignaux", + "Hannaway", + "Sykora", + "Brealey", + "Harness", + "Profit", + "Goldsbury", + "Brands", + "Godmar", + "Binden", + "Kondratenya", + "Warsap", + "Rumble", + "Maudson", + "Demer", + "Laxtonne", + "Kmietsch", + "Colten", + "Raysdale", + "Gadd", + "Blanche", + "Viant", + "Daskiewicz", + "Macura", + "Crouch", + "Janicijevic", + "Oade", + "Fancourt", + "Dimitriev", + "Earnshaw", + "Wing", + "Fountain", + "Fearey", + "Nottram", + "Bescoby", + "Jeandeau", + "Mapowder", + "Iacobo", + "Rabjohns", + "Dean", + "Whiterod", + "Mathiasen", + "Josephson", + "Boc", + "Olivet", + "Yeardley", + "Labuschagne", + "Curmi", + "Rogger", + "Tesoe", + "Mellhuish", + "Malan", + "McArt", + "Ing", + "Renowden", + "Mellsop", + "Critchlow", + "Seedhouse", + "Tiffin", + "Chirm", + "Oldknow", + "Wolffers", + "Dainter", + "Bundy", + "Copplestone", + "Moses", + "Weedon", + "Borzone", + "Craigg", + "Pyrah", + "Shoorbrooke", + "Jeandeau", + "Halgarth", + "Bamlett", + "Greally", + "Abrahamovitz", + "Oger", + "Mandrake", + "Craigg", + "Stenning", + "Tommei", + "Mapother", + "Cree", + "Clandillon", + "Thorlby", + "Careswell", + "Woolnough", + "McMeekin", + "Woodman", + "Mougin", + "Burchill", + "Pegg", + "Morin", + "Eskriett", + "Gelderd", + "Latham", + "Siney", + "Freen", + "Walrond", + "Bell", + "Twigley", + "D'Souza", + "Anton", + "Doyle", + "Pieters", + "Rosenvasser", + "Mackneis", + "Brisse", + "Boffin", + "Rushe", + "Cozens", + "Bensusan", + "Plampin", + "Gauford", + "Lecky", + "Belton", + "Fleming", + "Gent", + "Bunclark", + "Climar", + "Milner", + "Karolovsky", + "Claesens", + "Oleksiak", + "Barkway", + "Glenister", + "Steynor", + "Hecks", + "Rollo", + "Elcoux", + "Altham", + "Veschambes", + "Livingstone", + "Miroy", + "Edy", + "Bendle", + "Widdall", + "Onions", + "Devita", + "McOwan", + "Ahearne", + "Wisniowski", + "Pask", + "Ciccottini", + "Parlatt", + "Gindghill", + "Marquess", + "Claworth", + "Veel", + "Fairbairn", + "Galletley", + "Glew", + "Gillice", + "Liddyard", + "Babin", + "Ryson", + "Kyteley", + "Toms", + "Downton", + "Mougel", + "Inglefield", + "Gaskins", + "Bradie", + "Stanbury", + "McMenamy", + "Cranstone", + "Thody", + "Iacovozzo", + "Theobalds", + "Perrins", + "Dyott", + "Hupe", + "Gelling", + "Eadington", + "Crumbie", + "Stainsby", + "Kolakowski", + "Norwich", + "Ehrat", + "Basnett", + "Marden", + "Godby", + "Kubacki", + "Wiles", + "Littrick", + "Chuck", + "Negus", + "Aisthorpe", + "Danelut", + "Helversen", + "McCombe", + "Dallender", + "Offner", + "Leser", + "Savin", + "Belcham", + "Pockett", + "Selway", + "Santostefano.", + "Telford", + "Presser", + "Haken", + "Wybourne", + "Reolfo", + "Mineghelli", + "Beverage", + "Grimsdike", + "Drogan", + "Bynert", + "Boothman", + "Postle", + "Baskwell", + "Branno", + "Hechlin", + "Geake", + "Morstatt", + "Towne", + "Phillott", + "Doumerc", + "Ladewig", + "Sexty", + "Sleigh", + "Simonaitis", + "Han", + "Crommett", + "Blowes", + "Floyde", + "Delgardo", + "Brounsell", + "Klimowski", + "Jaffray", + "Kingzeth", + "Pithie", + "Eriksson", + "Gudgin", + "Hamal", + "Hooks", + "Rosle", + "Braysher", + "O'Curneen", + "Millett", + "Woofinden", + "Lillistone", + "Broxis", + "Mochar", + "Drewell", + "Hedgeman", + "Wharf", + "Lambden", + "Lambol", + "Slowcock", + "Cicchillo", + "Trineman", + "Sinyard", + "Brandone", + "Masding", + "Britnell", + "Quinlan", + "Arnopp", + "Jeratt", + "Bantick", + "Craigs", + "Pantling", + "Klais", + "Pickvance", + "Goodwill", + "McGavin", + "Esslemont", + "Bakewell", + "Downer", + "Scallan", + "Ronchka", + "Scholcroft", + "Van Der Walt", + "Armfield", + "Chalker", + "Chinge", + "Yakubov", + "Folkerd", + "Manon", + "Gookey", + "Connold", + "Dusey", + "Muselli", + "Skala", + "Dibbin", + "Kreber", + "De Blasi", + "Drei", + "Argo", + "Maudson", + "Stanlick", + "Steinham", + "Dallewater", + "Litchmore", + "Mathie", + "Gook", + "Forrestor", + "Ferreira", + "Budd", + "Joskowitz", + "Whetnall", + "Beany", + "Keymar", + "Merrin", + "Waldera", + "O'Gleasane", + "Duiged", + "Cumo", + "Giddings", + "Craker", + "Olenov", + "Whayman", + "Raoux", + "Delete", + "McDell", + "Gauntlett", + "Gomby", + "Rottgers", + "Spraggon", + "Orth", + "Shortan", + "Lineen", + "Monkhouse", + "Di Domenico", + "Brinsden", + "MacCallister", + "Sieghard", + "Pheasant", + "Cloney", + "Igglesden", + "Checklin", + "Grosier", + "Garnett", + "Vasnetsov", + "Chsteney", + "Manifield", + "Coutts", + "Bagshawe", + "Pryn", + "Dunstall", + "Rowlings", + "Whines", + "Bish", + "Solomon", + "Mackay", + "Daugherty", + "Gutierrez", + "Goff", + "Villanueva", + "Heath", + "Serrano", + "Munro", + "Levine", + "Barrett", + "Bateman", + "Colon", + "Alford", + "Whitehouse", + "Mendoza", + "Keith", + "Orr", + "Shepherd", + "North", + "Steele", + "Morales", + "Shea", + "Olsen", + "Wormald", + "Torres", + "Haines", + "Kerr", + "Reeves", + "Bates", + "Potts", + "Foreman", + "Herrera", + "Mccoy", + "Fulton", + "Charles", + "Clay", + "Estes", + "Mata", + "Childs", + "Kendall", + "Wallace", + "Thorpe", + "Oconnell", + "Waters", + "Roth", + "Barker", + "Fritz", + "Singleton", + "Sharpe", + "Little", + "Oliver", + "Ayala", + "Khan", + "Braun", + "Dean", + "Stout", + "Adamson", + "Tate", + "Juarez", + "Pickett", + "Burke", + "Gordon", + "Mackenzie", + "Bloggs", + "Read", + "Britton", + "Jefferson", + "Lutz", + "Chen", + "Wagstaff", + "Coates", + "Gilliam", + "Mullins", + "Ryan", + "Moon", + "Thompson", + "Abbott", + "Cotton", + "Barajas", + "Chan", + "Bostock", + "Spencer", + "Sparrow", + "Robinson", + "Morrison", + "Aguirre", + "Clayton", + "Hope", + "Swanson", + "Ochoa", + "Ruiz", + "Truong", + "Gibbons", + "Daniel", + "Zimmerman", + "Flynn", + "Keeling", + "Greenaway", + "Edwards" + ] +} diff --git a/bot/resources/holidays/halloween/spookyrating/baby.jpeg b/bot/resources/holidays/halloween/spookyrating/baby.jpeg new file mode 100644 index 00000000..199f8bca Binary files /dev/null and b/bot/resources/holidays/halloween/spookyrating/baby.jpeg differ diff --git a/bot/resources/holidays/halloween/spookyrating/candle.jpeg b/bot/resources/holidays/halloween/spookyrating/candle.jpeg new file mode 100644 index 00000000..9913752b Binary files /dev/null and b/bot/resources/holidays/halloween/spookyrating/candle.jpeg differ diff --git a/bot/resources/holidays/halloween/spookyrating/clown.jpeg b/bot/resources/holidays/halloween/spookyrating/clown.jpeg new file mode 100644 index 00000000..f23c4f70 Binary files /dev/null and b/bot/resources/holidays/halloween/spookyrating/clown.jpeg differ diff --git a/bot/resources/holidays/halloween/spookyrating/costume.jpeg b/bot/resources/holidays/halloween/spookyrating/costume.jpeg new file mode 100644 index 00000000..b3c21af0 Binary files /dev/null and b/bot/resources/holidays/halloween/spookyrating/costume.jpeg differ diff --git a/bot/resources/holidays/halloween/spookyrating/devil.jpeg b/bot/resources/holidays/halloween/spookyrating/devil.jpeg new file mode 100644 index 00000000..4f45aaa7 Binary files /dev/null and b/bot/resources/holidays/halloween/spookyrating/devil.jpeg differ diff --git a/bot/resources/holidays/halloween/spookyrating/ghost.jpeg b/bot/resources/holidays/halloween/spookyrating/ghost.jpeg new file mode 100644 index 00000000..0cb13346 Binary files /dev/null and b/bot/resources/holidays/halloween/spookyrating/ghost.jpeg differ diff --git a/bot/resources/holidays/halloween/spookyrating/jackolantern.jpeg b/bot/resources/holidays/halloween/spookyrating/jackolantern.jpeg new file mode 100644 index 00000000..d7cf3d08 Binary files /dev/null and b/bot/resources/holidays/halloween/spookyrating/jackolantern.jpeg differ diff --git a/bot/resources/holidays/halloween/spookyrating/necromancer.jpeg b/bot/resources/holidays/halloween/spookyrating/necromancer.jpeg new file mode 100644 index 00000000..60b1e689 Binary files /dev/null and b/bot/resources/holidays/halloween/spookyrating/necromancer.jpeg differ diff --git a/bot/resources/holidays/halloween/spookyrating/tiger.jpeg b/bot/resources/holidays/halloween/spookyrating/tiger.jpeg new file mode 100644 index 00000000..0419f5df Binary files /dev/null and b/bot/resources/holidays/halloween/spookyrating/tiger.jpeg differ -- cgit v1.2.3 From 7ea66723f5f9c09e77e0b063002d4b222e7cc9d0 Mon Sep 17 00:00:00 2001 From: Janine vN Date: Sat, 4 Sep 2021 23:35:00 -0400 Subject: Move Valentines to Holidays folder Moves the valentine's day features to the holidays folders. Corrected the paths to reflect the folder moves. --- bot/exts/holidays/valentines/__init__.py | 0 bot/exts/holidays/valentines/be_my_valentine.py | 192 +++++++++++++++ bot/exts/holidays/valentines/lovecalculator.py | 99 ++++++++ bot/exts/holidays/valentines/movie_generator.py | 67 ++++++ bot/exts/holidays/valentines/myvalenstate.py | 82 +++++++ bot/exts/holidays/valentines/pickuplines.py | 41 ++++ bot/exts/holidays/valentines/savethedate.py | 38 +++ bot/exts/holidays/valentines/valentine_zodiac.py | 146 ++++++++++++ bot/exts/holidays/valentines/whoisvalentine.py | 49 ++++ bot/exts/valentines/__init__.py | 0 bot/exts/valentines/be_my_valentine.py | 192 --------------- bot/exts/valentines/lovecalculator.py | 99 -------- bot/exts/valentines/movie_generator.py | 67 ------ bot/exts/valentines/myvalenstate.py | 82 ------- bot/exts/valentines/pickuplines.py | 41 ---- bot/exts/valentines/savethedate.py | 38 --- bot/exts/valentines/valentine_zodiac.py | 146 ------------ bot/exts/valentines/whoisvalentine.py | 49 ---- .../valentines/bemyvalentine_valentines.json | 45 ++++ bot/resources/holidays/valentines/date_ideas.json | 125 ++++++++++ .../holidays/valentines/love_matches.json | 58 +++++ .../holidays/valentines/pickup_lines.json | 97 ++++++++ bot/resources/holidays/valentines/valenstates.json | 122 ++++++++++ .../holidays/valentines/valentine_facts.json | 24 ++ .../holidays/valentines/zodiac_compatibility.json | 262 +++++++++++++++++++++ .../holidays/valentines/zodiac_explanation.json | 122 ++++++++++ .../valentines/bemyvalentine_valentines.json | 45 ---- bot/resources/valentines/date_ideas.json | 125 ---------- bot/resources/valentines/love_matches.json | 58 ----- bot/resources/valentines/pickup_lines.json | 97 -------- bot/resources/valentines/valenstates.json | 122 ---------- bot/resources/valentines/valentine_facts.json | 24 -- bot/resources/valentines/zodiac_compatibility.json | 262 --------------------- bot/resources/valentines/zodiac_explanation.json | 122 ---------- 34 files changed, 1569 insertions(+), 1569 deletions(-) create mode 100644 bot/exts/holidays/valentines/__init__.py create mode 100644 bot/exts/holidays/valentines/be_my_valentine.py create mode 100644 bot/exts/holidays/valentines/lovecalculator.py create mode 100644 bot/exts/holidays/valentines/movie_generator.py create mode 100644 bot/exts/holidays/valentines/myvalenstate.py create mode 100644 bot/exts/holidays/valentines/pickuplines.py create mode 100644 bot/exts/holidays/valentines/savethedate.py create mode 100644 bot/exts/holidays/valentines/valentine_zodiac.py create mode 100644 bot/exts/holidays/valentines/whoisvalentine.py delete mode 100644 bot/exts/valentines/__init__.py delete mode 100644 bot/exts/valentines/be_my_valentine.py delete mode 100644 bot/exts/valentines/lovecalculator.py delete mode 100644 bot/exts/valentines/movie_generator.py delete mode 100644 bot/exts/valentines/myvalenstate.py delete mode 100644 bot/exts/valentines/pickuplines.py delete mode 100644 bot/exts/valentines/savethedate.py delete mode 100644 bot/exts/valentines/valentine_zodiac.py delete mode 100644 bot/exts/valentines/whoisvalentine.py create mode 100644 bot/resources/holidays/valentines/bemyvalentine_valentines.json create mode 100644 bot/resources/holidays/valentines/date_ideas.json create mode 100644 bot/resources/holidays/valentines/love_matches.json create mode 100644 bot/resources/holidays/valentines/pickup_lines.json create mode 100644 bot/resources/holidays/valentines/valenstates.json create mode 100644 bot/resources/holidays/valentines/valentine_facts.json create mode 100644 bot/resources/holidays/valentines/zodiac_compatibility.json create mode 100644 bot/resources/holidays/valentines/zodiac_explanation.json delete mode 100644 bot/resources/valentines/bemyvalentine_valentines.json delete mode 100644 bot/resources/valentines/date_ideas.json delete mode 100644 bot/resources/valentines/love_matches.json delete mode 100644 bot/resources/valentines/pickup_lines.json delete mode 100644 bot/resources/valentines/valenstates.json delete mode 100644 bot/resources/valentines/valentine_facts.json delete mode 100644 bot/resources/valentines/zodiac_compatibility.json delete mode 100644 bot/resources/valentines/zodiac_explanation.json (limited to 'bot') diff --git a/bot/exts/holidays/valentines/__init__.py b/bot/exts/holidays/valentines/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bot/exts/holidays/valentines/be_my_valentine.py b/bot/exts/holidays/valentines/be_my_valentine.py new file mode 100644 index 00000000..4d454c3a --- /dev/null +++ b/bot/exts/holidays/valentines/be_my_valentine.py @@ -0,0 +1,192 @@ +import logging +import random +from json import loads +from pathlib import Path + +import discord +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Channels, Colours, Lovefest, Month +from bot.utils.decorators import in_month +from bot.utils.extensions import invoke_help_command + +log = logging.getLogger(__name__) + +HEART_EMOJIS = [":heart:", ":gift_heart:", ":revolving_hearts:", ":sparkling_heart:", ":two_hearts:"] + + +class BeMyValentine(commands.Cog): + """A cog that sends Valentines to other users!""" + + def __init__(self, bot: Bot): + self.bot = bot + self.valentines = self.load_json() + + @staticmethod + def load_json() -> dict: + """Load Valentines messages from the static resources.""" + p = Path("bot/resources/holidays/valentines/bemyvalentine_valentines.json") + return loads(p.read_text("utf8")) + + @in_month(Month.FEBRUARY) + @commands.group(name="lovefest") + async def lovefest_role(self, ctx: commands.Context) -> None: + """ + Subscribe or unsubscribe from the lovefest role. + + The lovefest role makes you eligible to receive anonymous valentines from other users. + + 1) use the command \".lovefest sub\" to get the lovefest role. + 2) use the command \".lovefest unsub\" to get rid of the lovefest role. + """ + if not ctx.invoked_subcommand: + await invoke_help_command(ctx) + + @lovefest_role.command(name="sub") + async def add_role(self, ctx: commands.Context) -> None: + """Adds the lovefest role.""" + user = ctx.author + role = ctx.guild.get_role(Lovefest.role_id) + if role not in ctx.author.roles: + await user.add_roles(role) + await ctx.send("The Lovefest role has been added !") + else: + await ctx.send("You already have the role !") + + @lovefest_role.command(name="unsub") + async def remove_role(self, ctx: commands.Context) -> None: + """Removes the lovefest role.""" + user = ctx.author + role = ctx.guild.get_role(Lovefest.role_id) + if role not in ctx.author.roles: + await ctx.send("You dont have the lovefest role.") + else: + await user.remove_roles(role) + await ctx.send("The lovefest role has been successfully removed!") + + @commands.cooldown(1, 1800, commands.BucketType.user) + @commands.group(name="bemyvalentine", invoke_without_command=True) + async def send_valentine( + self, ctx: commands.Context, user: discord.Member, *, valentine_type: str = None + ) -> None: + """ + Send a valentine to a specified user with the lovefest role. + + syntax: .bemyvalentine [user] [p/poem/c/compliment/or you can type your own valentine message] + (optional) + + example: .bemyvalentine Iceman#6508 p (sends a poem to Iceman) + example: .bemyvalentine Iceman Hey I love you, wanna hang around ? (sends the custom message to Iceman) + NOTE : AVOID TAGGING THE USER MOST OF THE TIMES.JUST TRIM THE '@' when using this command. + """ + if ctx.guild is None: + # This command should only be used in the server + raise commands.UserInputError("You are supposed to use this command in the server.") + + if Lovefest.role_id not in [role.id for role in user.roles]: + raise commands.UserInputError( + f"You cannot send a valentine to {user} as they do not have the lovefest role!" + ) + + if user == ctx.author: + # Well a user can't valentine himself/herself. + raise commands.UserInputError("Come on, you can't send a valentine to yourself :expressionless:") + + emoji_1, emoji_2 = self.random_emoji() + channel = self.bot.get_channel(Channels.community_bot_commands) + valentine, title = self.valentine_check(valentine_type) + + embed = discord.Embed( + title=f"{emoji_1} {title} {user.display_name} {emoji_2}", + description=f"{valentine} \n **{emoji_2}From {ctx.author}{emoji_1}**", + color=Colours.pink + ) + await channel.send(user.mention, embed=embed) + + @commands.cooldown(1, 1800, commands.BucketType.user) + @send_valentine.command(name="secret") + async def anonymous( + self, ctx: commands.Context, user: discord.Member, *, valentine_type: str = None + ) -> None: + """ + Send an anonymous Valentine via DM to to a specified user with the lovefest role. + + syntax : .bemyvalentine secret [user] [p/poem/c/compliment/or you can type your own valentine message] + (optional) + + example : .bemyvalentine secret Iceman#6508 p (sends a poem to Iceman in DM making you anonymous) + example : .bemyvalentine secret Iceman#6508 Hey I love you, wanna hang around ? (sends the custom message to + Iceman in DM making you anonymous) + """ + if Lovefest.role_id not in [role.id for role in user.roles]: + await ctx.message.delete() + raise commands.UserInputError( + f"You cannot send a valentine to {user} as they do not have the lovefest role!" + ) + + if user == ctx.author: + # Well a user cant valentine himself/herself. + raise commands.UserInputError("Come on, you can't send a valentine to yourself :expressionless:") + + emoji_1, emoji_2 = self.random_emoji() + valentine, title = self.valentine_check(valentine_type) + + embed = discord.Embed( + title=f"{emoji_1}{title} {user.display_name}{emoji_2}", + description=f"{valentine} \n **{emoji_2}From anonymous{emoji_1}**", + color=Colours.pink + ) + await ctx.message.delete() + try: + await user.send(embed=embed) + except discord.Forbidden: + raise commands.UserInputError(f"{user} has DMs disabled, so I couldn't send the message. Sorry!") + else: + await ctx.author.send(f"Your message has been sent to {user}") + + def valentine_check(self, valentine_type: str) -> tuple[str, str]: + """Return the appropriate Valentine type & title based on the invoking user's input.""" + if valentine_type is None: + return self.random_valentine() + + elif valentine_type.lower() in ["p", "poem"]: + return self.valentine_poem(), "A poem dedicated to" + + elif valentine_type.lower() in ["c", "compliment"]: + return self.valentine_compliment(), "A compliment for" + + else: + # in this case, the user decides to type his own valentine. + return valentine_type, "A message for" + + @staticmethod + def random_emoji() -> tuple[str, str]: + """Return two random emoji from the module-defined constants.""" + emoji_1 = random.choice(HEART_EMOJIS) + emoji_2 = random.choice(HEART_EMOJIS) + return emoji_1, emoji_2 + + def random_valentine(self) -> tuple[str, str]: + """Grabs a random poem or a compliment (any message).""" + valentine_poem = random.choice(self.valentines["valentine_poems"]) + valentine_compliment = random.choice(self.valentines["valentine_compliments"]) + random_valentine = random.choice([valentine_compliment, valentine_poem]) + if random_valentine == valentine_poem: + title = "A poem dedicated to" + else: + title = "A compliment for " + return random_valentine, title + + def valentine_poem(self) -> str: + """Grabs a random poem.""" + return random.choice(self.valentines["valentine_poems"]) + + def valentine_compliment(self) -> str: + """Grabs a random compliment.""" + return random.choice(self.valentines["valentine_compliments"]) + + +def setup(bot: Bot) -> None: + """Load the Be my Valentine Cog.""" + bot.add_cog(BeMyValentine(bot)) diff --git a/bot/exts/holidays/valentines/lovecalculator.py b/bot/exts/holidays/valentines/lovecalculator.py new file mode 100644 index 00000000..3999db2b --- /dev/null +++ b/bot/exts/holidays/valentines/lovecalculator.py @@ -0,0 +1,99 @@ +import bisect +import hashlib +import json +import logging +import random +from pathlib import Path +from typing import Coroutine, Optional + +import discord +from discord import Member +from discord.ext import commands +from discord.ext.commands import BadArgument, Cog, clean_content + +from bot.bot import Bot +from bot.constants import Channels, Client, Lovefest, Month +from bot.utils.decorators import in_month + +log = logging.getLogger(__name__) + +LOVE_DATA = json.loads(Path("bot/resources/holidays/valentines/love_matches.json").read_text("utf8")) +LOVE_DATA = sorted((int(key), value) for key, value in LOVE_DATA.items()) + + +class LoveCalculator(Cog): + """A cog for calculating the love between two people.""" + + @in_month(Month.FEBRUARY) + @commands.command(aliases=("love_calculator", "love_calc")) + @commands.cooldown(rate=1, per=5, type=commands.BucketType.user) + async def love(self, ctx: commands.Context, who: Member, whom: Optional[Member] = None) -> None: + """ + Tells you how much the two love each other. + + This command requires at least one member as input, if two are given love will be calculated between + those two users, if only one is given, the second member is asusmed to be the invoker. + Members are converted from: + - User ID + - Mention + - name#discrim + - name + - nickname + + Any two arguments will always yield the same result, regardless of the order of arguments: + Running .love @joe#6000 @chrisjl#2655 will always yield the same result. + Running .love @chrisjl#2655 @joe#6000 will yield the same result as before. + """ + if ( + Lovefest.role_id not in [role.id for role in who.roles] + or (whom is not None and Lovefest.role_id not in [role.id for role in whom.roles]) + ): + raise BadArgument( + "This command can only be ran against members with the lovefest role! " + "This role be can assigned by running " + f"`{Client.prefix}lovefest sub` in <#{Channels.community_bot_commands}>." + ) + + if whom is None: + whom = ctx.author + + def normalize(arg: Member) -> Coroutine: + # This has to be done manually to be applied to usernames + return clean_content(escape_markdown=True).convert(ctx, str(arg)) + + # Sort to ensure same result for same input, regardless of order + who, whom = sorted([await normalize(arg) for arg in (who, whom)]) + + # Hash inputs to guarantee consistent results (hashing algorithm choice arbitrary) + # + # hashlib is used over the builtin hash() to guarantee same result over multiple runtimes + m = hashlib.sha256(who.encode() + whom.encode()) + # Mod 101 for [0, 100] + love_percent = sum(m.digest()) % 101 + + # We need the -1 due to how bisect returns the point + # see the documentation for further detail + # https://docs.python.org/3/library/bisect.html#bisect.bisect + index = bisect.bisect(LOVE_DATA, (love_percent,)) - 1 + # We already have the nearest "fit" love level + # We only need the dict, so we can ditch the first element + _, data = LOVE_DATA[index] + + status = random.choice(data["titles"]) + embed = discord.Embed( + title=status, + description=f"{who} \N{HEAVY BLACK HEART} {whom} scored {love_percent}%!\n\u200b", + color=discord.Color.dark_magenta() + ) + embed.add_field( + name="A letter from Dr. Love:", + value=data["text"] + ) + embed.set_footer(text=f"You can unsubscribe from lovefest by using {Client.prefix}lovefest unsub") + + await ctx.send(embed=embed) + + +def setup(bot: Bot) -> None: + """Load the Love calculator Cog.""" + bot.add_cog(LoveCalculator()) diff --git a/bot/exts/holidays/valentines/movie_generator.py b/bot/exts/holidays/valentines/movie_generator.py new file mode 100644 index 00000000..d2dc8213 --- /dev/null +++ b/bot/exts/holidays/valentines/movie_generator.py @@ -0,0 +1,67 @@ +import logging +import random +from os import environ + +import discord +from discord.ext import commands + +from bot.bot import Bot + +TMDB_API_KEY = environ.get("TMDB_API_KEY") + +log = logging.getLogger(__name__) + + +class RomanceMovieFinder(commands.Cog): + """A Cog that returns a random romance movie suggestion to a user.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @commands.command(name="romancemovie") + async def romance_movie(self, ctx: commands.Context) -> None: + """Randomly selects a romance movie and displays information about it.""" + # Selecting a random int to parse it to the page parameter + random_page = random.randint(0, 20) + # TMDB api params + params = { + "api_key": TMDB_API_KEY, + "language": "en-US", + "sort_by": "popularity.desc", + "include_adult": "false", + "include_video": "false", + "page": random_page, + "with_genres": "10749" + } + # The api request url + request_url = "https://api.themoviedb.org/3/discover/movie" + async with self.bot.http_session.get(request_url, params=params) as resp: + # Trying to load the json file returned from the api + try: + data = await resp.json() + # Selecting random result from results object in the json file + selected_movie = random.choice(data["results"]) + + embed = discord.Embed( + title=f":sparkling_heart: {selected_movie['title']} :sparkling_heart:", + description=selected_movie["overview"], + ) + embed.set_image(url=f"http://image.tmdb.org/t/p/w200/{selected_movie['poster_path']}") + embed.add_field(name="Release date :clock1:", value=selected_movie["release_date"]) + embed.add_field(name="Rating :star2:", value=selected_movie["vote_average"]) + embed.set_footer(text="This product uses the TMDb API but is not endorsed or certified by TMDb.") + embed.set_thumbnail(url="https://i.imgur.com/LtFtC8H.png") + await ctx.send(embed=embed) + except KeyError: + warning_message = ( + "A KeyError was raised while fetching information on the movie. The API service" + " could be unavailable or the API key could be set incorrectly." + ) + embed = discord.Embed(title=warning_message) + log.warning(warning_message) + await ctx.send(embed=embed) + + +def setup(bot: Bot) -> None: + """Load the Romance movie Cog.""" + bot.add_cog(RomanceMovieFinder(bot)) diff --git a/bot/exts/holidays/valentines/myvalenstate.py b/bot/exts/holidays/valentines/myvalenstate.py new file mode 100644 index 00000000..4b547d9b --- /dev/null +++ b/bot/exts/holidays/valentines/myvalenstate.py @@ -0,0 +1,82 @@ +import collections +import json +import logging +from pathlib import Path +from random import choice + +import discord +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Colours + +log = logging.getLogger(__name__) + +STATES = json.loads(Path("bot/resources/holidays/valentines/valenstates.json").read_text("utf8")) + + +class MyValenstate(commands.Cog): + """A Cog to find your most likely Valentine's vacation destination.""" + + def levenshtein(self, source: str, goal: str) -> int: + """Calculates the Levenshtein Distance between source and goal.""" + if len(source) < len(goal): + return self.levenshtein(goal, source) + if len(source) == 0: + return len(goal) + if len(goal) == 0: + return len(source) + + pre_row = list(range(0, len(source) + 1)) + for i, source_c in enumerate(source): + cur_row = [i + 1] + for j, goal_c in enumerate(goal): + if source_c != goal_c: + cur_row.append(min(pre_row[j], pre_row[j + 1], cur_row[j]) + 1) + else: + cur_row.append(min(pre_row[j], pre_row[j + 1], cur_row[j])) + pre_row = cur_row + return pre_row[-1] + + @commands.command() + async def myvalenstate(self, ctx: commands.Context, *, name: str = None) -> None: + """Find the vacation spot(s) with the most matching characters to the invoking user.""" + eq_chars = collections.defaultdict(int) + if name is None: + author = ctx.author.name.lower().replace(" ", "") + else: + author = name.lower().replace(" ", "") + + for state in STATES.keys(): + lower_state = state.lower().replace(" ", "") + eq_chars[state] = self.levenshtein(author, lower_state) + + matches = [x for x, y in eq_chars.items() if y == min(eq_chars.values())] + valenstate = choice(matches) + matches.remove(valenstate) + + embed_title = "But there are more!" + if len(matches) > 1: + leftovers = f"{', '.join(matches[:-2])}, and {matches[-1]}" + embed_text = f"You have {len(matches)} more matches, these being {leftovers}." + elif len(matches) == 1: + embed_title = "But there's another one!" + embed_text = f"You have another match, this being {matches[0]}." + else: + embed_title = "You have a true match!" + embed_text = "This state is your true Valenstate! There are no states that would suit" \ + " you better" + + embed = discord.Embed( + title=f"Your Valenstate is {valenstate} \u2764", + description=STATES[valenstate]["text"], + colour=Colours.pink + ) + embed.add_field(name=embed_title, value=embed_text) + embed.set_image(url=STATES[valenstate]["flag"]) + await ctx.send(embed=embed) + + +def setup(bot: Bot) -> None: + """Load the Valenstate Cog.""" + bot.add_cog(MyValenstate()) diff --git a/bot/exts/holidays/valentines/pickuplines.py b/bot/exts/holidays/valentines/pickuplines.py new file mode 100644 index 00000000..bc4b88c6 --- /dev/null +++ b/bot/exts/holidays/valentines/pickuplines.py @@ -0,0 +1,41 @@ +import logging +import random +from json import loads +from pathlib import Path + +import discord +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Colours + +log = logging.getLogger(__name__) + +PICKUP_LINES = loads(Path("bot/resources/holidays/valentines/pickup_lines.json").read_text("utf8")) + + +class PickupLine(commands.Cog): + """A cog that gives random cheesy pickup lines.""" + + @commands.command() + async def pickupline(self, ctx: commands.Context) -> None: + """ + Gives you a random pickup line. + + Note that most of them are very cheesy. + """ + random_line = random.choice(PICKUP_LINES["lines"]) + embed = discord.Embed( + title=":cheese: Your pickup line :cheese:", + description=random_line["line"], + color=Colours.pink + ) + embed.set_thumbnail( + url=random_line.get("image", PICKUP_LINES["placeholder"]) + ) + await ctx.send(embed=embed) + + +def setup(bot: Bot) -> None: + """Load the Pickup lines Cog.""" + bot.add_cog(PickupLine()) diff --git a/bot/exts/holidays/valentines/savethedate.py b/bot/exts/holidays/valentines/savethedate.py new file mode 100644 index 00000000..3638c1ef --- /dev/null +++ b/bot/exts/holidays/valentines/savethedate.py @@ -0,0 +1,38 @@ +import logging +import random +from json import loads +from pathlib import Path + +import discord +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Colours + +log = logging.getLogger(__name__) + +HEART_EMOJIS = [":heart:", ":gift_heart:", ":revolving_hearts:", ":sparkling_heart:", ":two_hearts:"] + +VALENTINES_DATES = loads(Path("bot/resources/holidays/valentines/date_ideas.json").read_text("utf8")) + + +class SaveTheDate(commands.Cog): + """A cog that gives random suggestion for a Valentine's date.""" + + @commands.command() + async def savethedate(self, ctx: commands.Context) -> None: + """Gives you ideas for what to do on a date with your valentine.""" + random_date = random.choice(VALENTINES_DATES["ideas"]) + emoji_1 = random.choice(HEART_EMOJIS) + emoji_2 = random.choice(HEART_EMOJIS) + embed = discord.Embed( + title=f"{emoji_1}{random_date['name']}{emoji_2}", + description=f"{random_date['description']}", + colour=Colours.pink + ) + await ctx.send(embed=embed) + + +def setup(bot: Bot) -> None: + """Load the Save the date Cog.""" + bot.add_cog(SaveTheDate()) diff --git a/bot/exts/holidays/valentines/valentine_zodiac.py b/bot/exts/holidays/valentines/valentine_zodiac.py new file mode 100644 index 00000000..d1b3a630 --- /dev/null +++ b/bot/exts/holidays/valentines/valentine_zodiac.py @@ -0,0 +1,146 @@ +import calendar +import json +import logging +import random +from datetime import datetime +from pathlib import Path +from typing import Union + +import discord +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Colours + +log = logging.getLogger(__name__) + +LETTER_EMOJI = ":love_letter:" +HEART_EMOJIS = [":heart:", ":gift_heart:", ":revolving_hearts:", ":sparkling_heart:", ":two_hearts:"] + + +class ValentineZodiac(commands.Cog): + """A Cog that returns a counter compatible zodiac sign to the given user's zodiac sign.""" + + def __init__(self): + self.zodiacs, self.zodiac_fact = self.load_comp_json() + + @staticmethod + def load_comp_json() -> tuple[dict, dict]: + """Load zodiac compatibility from static JSON resource.""" + explanation_file = Path("bot/resources/holidays/valentines/zodiac_explanation.json") + compatibility_file = Path("bot/resources/holidays/valentines/zodiac_compatibility.json") + + zodiac_fact = json.loads(explanation_file.read_text("utf8")) + + for zodiac_data in zodiac_fact.values(): + zodiac_data["start_at"] = datetime.fromisoformat(zodiac_data["start_at"]) + zodiac_data["end_at"] = datetime.fromisoformat(zodiac_data["end_at"]) + + zodiacs = json.loads(compatibility_file.read_text("utf8")) + + return zodiacs, zodiac_fact + + def generate_invalidname_embed(self, zodiac: str) -> discord.Embed: + """Returns error embed.""" + embed = discord.Embed() + embed.color = Colours.soft_red + error_msg = f"**{zodiac}** is not a valid zodiac sign, here is the list of valid zodiac signs.\n" + names = list(self.zodiac_fact) + middle_index = len(names) // 2 + first_half_names = ", ".join(names[:middle_index]) + second_half_names = ", ".join(names[middle_index:]) + embed.description = error_msg + first_half_names + ",\n" + second_half_names + log.info("Invalid zodiac name provided.") + return embed + + def zodiac_build_embed(self, zodiac: str) -> discord.Embed: + """Gives informative zodiac embed.""" + zodiac = zodiac.capitalize() + embed = discord.Embed() + embed.color = Colours.pink + if zodiac in self.zodiac_fact: + log.trace("Making zodiac embed.") + embed.title = f"__{zodiac}__" + embed.description = self.zodiac_fact[zodiac]["About"] + embed.add_field(name="__Motto__", value=self.zodiac_fact[zodiac]["Motto"], inline=False) + embed.add_field(name="__Strengths__", value=self.zodiac_fact[zodiac]["Strengths"], inline=False) + embed.add_field(name="__Weaknesses__", value=self.zodiac_fact[zodiac]["Weaknesses"], inline=False) + embed.add_field(name="__Full form__", value=self.zodiac_fact[zodiac]["full_form"], inline=False) + embed.set_thumbnail(url=self.zodiac_fact[zodiac]["url"]) + else: + embed = self.generate_invalidname_embed(zodiac) + log.trace("Successfully created zodiac information embed.") + return embed + + def zodiac_date_verifier(self, query_date: datetime) -> str: + """Returns zodiac sign by checking date.""" + for zodiac_name, zodiac_data in self.zodiac_fact.items(): + if zodiac_data["start_at"].date() <= query_date.date() <= zodiac_data["end_at"].date(): + log.trace("Zodiac name sent.") + return zodiac_name + + @commands.group(name="zodiac", invoke_without_command=True) + async def zodiac(self, ctx: commands.Context, zodiac_sign: str) -> None: + """Provides information about zodiac sign by taking zodiac sign name as input.""" + final_embed = self.zodiac_build_embed(zodiac_sign) + await ctx.send(embed=final_embed) + log.trace("Embed successfully sent.") + + @zodiac.command(name="date") + async def date_and_month(self, ctx: commands.Context, date: int, month: Union[int, str]) -> None: + """Provides information about zodiac sign by taking month and date as input.""" + if isinstance(month, str): + month = month.capitalize() + try: + month = list(calendar.month_abbr).index(month[:3]) + log.trace("Valid month name entered by user") + except ValueError: + log.info("Invalid month name entered by user") + await ctx.send(f"Sorry, but `{month}` is not a valid month name.") + return + if (month == 1 and 1 <= date <= 19) or (month == 12 and 22 <= date <= 31): + zodiac = "capricorn" + final_embed = self.zodiac_build_embed(zodiac) + else: + try: + zodiac_sign_based_on_date = self.zodiac_date_verifier(datetime(2020, month, date)) + log.trace("zodiac sign based on month and date received.") + except ValueError as e: + final_embed = discord.Embed() + final_embed.color = Colours.soft_red + final_embed.description = f"Zodiac sign could not be found because.\n```\n{e}\n```" + log.info(f"Error in 'zodiac date' command:\n{e}.") + else: + final_embed = self.zodiac_build_embed(zodiac_sign_based_on_date) + + await ctx.send(embed=final_embed) + log.trace("Embed from date successfully sent.") + + @zodiac.command(name="partnerzodiac", aliases=("partner",)) + async def partner_zodiac(self, ctx: commands.Context, zodiac_sign: str) -> None: + """Provides a random counter compatible zodiac sign to the given user's zodiac sign.""" + embed = discord.Embed() + embed.color = Colours.pink + zodiac_check = self.zodiacs.get(zodiac_sign.capitalize()) + if zodiac_check: + compatible_zodiac = random.choice(self.zodiacs[zodiac_sign.capitalize()]) + emoji1 = random.choice(HEART_EMOJIS) + emoji2 = random.choice(HEART_EMOJIS) + embed.title = "Zodiac Compatibility" + embed.description = ( + f"{zodiac_sign.capitalize()}{emoji1}{compatible_zodiac['Zodiac']}\n" + f"{emoji2}Compatibility meter : {compatible_zodiac['compatibility_score']}{emoji2}" + ) + embed.add_field( + name=f"A letter from Dr.Zodiac {LETTER_EMOJI}", + value=compatible_zodiac["description"] + ) + else: + embed = self.generate_invalidname_embed(zodiac_sign) + await ctx.send(embed=embed) + log.trace("Embed from date successfully sent.") + + +def setup(bot: Bot) -> None: + """Load the Valentine zodiac Cog.""" + bot.add_cog(ValentineZodiac()) diff --git a/bot/exts/holidays/valentines/whoisvalentine.py b/bot/exts/holidays/valentines/whoisvalentine.py new file mode 100644 index 00000000..67e46aa4 --- /dev/null +++ b/bot/exts/holidays/valentines/whoisvalentine.py @@ -0,0 +1,49 @@ +import json +import logging +from pathlib import Path +from random import choice + +import discord +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Colours + +log = logging.getLogger(__name__) + +FACTS = json.loads(Path("bot/resources/holidays/valentines/valentine_facts.json").read_text("utf8")) + + +class ValentineFacts(commands.Cog): + """A Cog for displaying facts about Saint Valentine.""" + + @commands.command(aliases=("whoisvalentine", "saint_valentine")) + async def who_is_valentine(self, ctx: commands.Context) -> None: + """Displays info about Saint Valentine.""" + embed = discord.Embed( + title="Who is Saint Valentine?", + description=FACTS["whois"], + color=Colours.pink + ) + embed.set_thumbnail( + url="https://upload.wikimedia.org/wikipedia/commons/thumb/f/f1/Saint_Valentine_-_" + "facial_reconstruction.jpg/1024px-Saint_Valentine_-_facial_reconstruction.jpg" + ) + + await ctx.send(embed=embed) + + @commands.command() + async def valentine_fact(self, ctx: commands.Context) -> None: + """Shows a random fact about Valentine's Day.""" + embed = discord.Embed( + title=choice(FACTS["titles"]), + description=choice(FACTS["text"]), + color=Colours.pink + ) + + await ctx.send(embed=embed) + + +def setup(bot: Bot) -> None: + """Load the Who is Valentine Cog.""" + bot.add_cog(ValentineFacts()) diff --git a/bot/exts/valentines/__init__.py b/bot/exts/valentines/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/bot/exts/valentines/be_my_valentine.py b/bot/exts/valentines/be_my_valentine.py deleted file mode 100644 index c238027a..00000000 --- a/bot/exts/valentines/be_my_valentine.py +++ /dev/null @@ -1,192 +0,0 @@ -import logging -import random -from json import loads -from pathlib import Path - -import discord -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Channels, Colours, Lovefest, Month -from bot.utils.decorators import in_month -from bot.utils.extensions import invoke_help_command - -log = logging.getLogger(__name__) - -HEART_EMOJIS = [":heart:", ":gift_heart:", ":revolving_hearts:", ":sparkling_heart:", ":two_hearts:"] - - -class BeMyValentine(commands.Cog): - """A cog that sends Valentines to other users!""" - - def __init__(self, bot: Bot): - self.bot = bot - self.valentines = self.load_json() - - @staticmethod - def load_json() -> dict: - """Load Valentines messages from the static resources.""" - p = Path("bot/resources/valentines/bemyvalentine_valentines.json") - return loads(p.read_text("utf8")) - - @in_month(Month.FEBRUARY) - @commands.group(name="lovefest") - async def lovefest_role(self, ctx: commands.Context) -> None: - """ - Subscribe or unsubscribe from the lovefest role. - - The lovefest role makes you eligible to receive anonymous valentines from other users. - - 1) use the command \".lovefest sub\" to get the lovefest role. - 2) use the command \".lovefest unsub\" to get rid of the lovefest role. - """ - if not ctx.invoked_subcommand: - await invoke_help_command(ctx) - - @lovefest_role.command(name="sub") - async def add_role(self, ctx: commands.Context) -> None: - """Adds the lovefest role.""" - user = ctx.author - role = ctx.guild.get_role(Lovefest.role_id) - if role not in ctx.author.roles: - await user.add_roles(role) - await ctx.send("The Lovefest role has been added !") - else: - await ctx.send("You already have the role !") - - @lovefest_role.command(name="unsub") - async def remove_role(self, ctx: commands.Context) -> None: - """Removes the lovefest role.""" - user = ctx.author - role = ctx.guild.get_role(Lovefest.role_id) - if role not in ctx.author.roles: - await ctx.send("You dont have the lovefest role.") - else: - await user.remove_roles(role) - await ctx.send("The lovefest role has been successfully removed!") - - @commands.cooldown(1, 1800, commands.BucketType.user) - @commands.group(name="bemyvalentine", invoke_without_command=True) - async def send_valentine( - self, ctx: commands.Context, user: discord.Member, *, valentine_type: str = None - ) -> None: - """ - Send a valentine to a specified user with the lovefest role. - - syntax: .bemyvalentine [user] [p/poem/c/compliment/or you can type your own valentine message] - (optional) - - example: .bemyvalentine Iceman#6508 p (sends a poem to Iceman) - example: .bemyvalentine Iceman Hey I love you, wanna hang around ? (sends the custom message to Iceman) - NOTE : AVOID TAGGING THE USER MOST OF THE TIMES.JUST TRIM THE '@' when using this command. - """ - if ctx.guild is None: - # This command should only be used in the server - raise commands.UserInputError("You are supposed to use this command in the server.") - - if Lovefest.role_id not in [role.id for role in user.roles]: - raise commands.UserInputError( - f"You cannot send a valentine to {user} as they do not have the lovefest role!" - ) - - if user == ctx.author: - # Well a user can't valentine himself/herself. - raise commands.UserInputError("Come on, you can't send a valentine to yourself :expressionless:") - - emoji_1, emoji_2 = self.random_emoji() - channel = self.bot.get_channel(Channels.community_bot_commands) - valentine, title = self.valentine_check(valentine_type) - - embed = discord.Embed( - title=f"{emoji_1} {title} {user.display_name} {emoji_2}", - description=f"{valentine} \n **{emoji_2}From {ctx.author}{emoji_1}**", - color=Colours.pink - ) - await channel.send(user.mention, embed=embed) - - @commands.cooldown(1, 1800, commands.BucketType.user) - @send_valentine.command(name="secret") - async def anonymous( - self, ctx: commands.Context, user: discord.Member, *, valentine_type: str = None - ) -> None: - """ - Send an anonymous Valentine via DM to to a specified user with the lovefest role. - - syntax : .bemyvalentine secret [user] [p/poem/c/compliment/or you can type your own valentine message] - (optional) - - example : .bemyvalentine secret Iceman#6508 p (sends a poem to Iceman in DM making you anonymous) - example : .bemyvalentine secret Iceman#6508 Hey I love you, wanna hang around ? (sends the custom message to - Iceman in DM making you anonymous) - """ - if Lovefest.role_id not in [role.id for role in user.roles]: - await ctx.message.delete() - raise commands.UserInputError( - f"You cannot send a valentine to {user} as they do not have the lovefest role!" - ) - - if user == ctx.author: - # Well a user cant valentine himself/herself. - raise commands.UserInputError("Come on, you can't send a valentine to yourself :expressionless:") - - emoji_1, emoji_2 = self.random_emoji() - valentine, title = self.valentine_check(valentine_type) - - embed = discord.Embed( - title=f"{emoji_1}{title} {user.display_name}{emoji_2}", - description=f"{valentine} \n **{emoji_2}From anonymous{emoji_1}**", - color=Colours.pink - ) - await ctx.message.delete() - try: - await user.send(embed=embed) - except discord.Forbidden: - raise commands.UserInputError(f"{user} has DMs disabled, so I couldn't send the message. Sorry!") - else: - await ctx.author.send(f"Your message has been sent to {user}") - - def valentine_check(self, valentine_type: str) -> tuple[str, str]: - """Return the appropriate Valentine type & title based on the invoking user's input.""" - if valentine_type is None: - return self.random_valentine() - - elif valentine_type.lower() in ["p", "poem"]: - return self.valentine_poem(), "A poem dedicated to" - - elif valentine_type.lower() in ["c", "compliment"]: - return self.valentine_compliment(), "A compliment for" - - else: - # in this case, the user decides to type his own valentine. - return valentine_type, "A message for" - - @staticmethod - def random_emoji() -> tuple[str, str]: - """Return two random emoji from the module-defined constants.""" - emoji_1 = random.choice(HEART_EMOJIS) - emoji_2 = random.choice(HEART_EMOJIS) - return emoji_1, emoji_2 - - def random_valentine(self) -> tuple[str, str]: - """Grabs a random poem or a compliment (any message).""" - valentine_poem = random.choice(self.valentines["valentine_poems"]) - valentine_compliment = random.choice(self.valentines["valentine_compliments"]) - random_valentine = random.choice([valentine_compliment, valentine_poem]) - if random_valentine == valentine_poem: - title = "A poem dedicated to" - else: - title = "A compliment for " - return random_valentine, title - - def valentine_poem(self) -> str: - """Grabs a random poem.""" - return random.choice(self.valentines["valentine_poems"]) - - def valentine_compliment(self) -> str: - """Grabs a random compliment.""" - return random.choice(self.valentines["valentine_compliments"]) - - -def setup(bot: Bot) -> None: - """Load the Be my Valentine Cog.""" - bot.add_cog(BeMyValentine(bot)) diff --git a/bot/exts/valentines/lovecalculator.py b/bot/exts/valentines/lovecalculator.py deleted file mode 100644 index 1cb10e64..00000000 --- a/bot/exts/valentines/lovecalculator.py +++ /dev/null @@ -1,99 +0,0 @@ -import bisect -import hashlib -import json -import logging -import random -from pathlib import Path -from typing import Coroutine, Optional - -import discord -from discord import Member -from discord.ext import commands -from discord.ext.commands import BadArgument, Cog, clean_content - -from bot.bot import Bot -from bot.constants import Channels, Client, Lovefest, Month -from bot.utils.decorators import in_month - -log = logging.getLogger(__name__) - -LOVE_DATA = json.loads(Path("bot/resources/valentines/love_matches.json").read_text("utf8")) -LOVE_DATA = sorted((int(key), value) for key, value in LOVE_DATA.items()) - - -class LoveCalculator(Cog): - """A cog for calculating the love between two people.""" - - @in_month(Month.FEBRUARY) - @commands.command(aliases=("love_calculator", "love_calc")) - @commands.cooldown(rate=1, per=5, type=commands.BucketType.user) - async def love(self, ctx: commands.Context, who: Member, whom: Optional[Member] = None) -> None: - """ - Tells you how much the two love each other. - - This command requires at least one member as input, if two are given love will be calculated between - those two users, if only one is given, the second member is asusmed to be the invoker. - Members are converted from: - - User ID - - Mention - - name#discrim - - name - - nickname - - Any two arguments will always yield the same result, regardless of the order of arguments: - Running .love @joe#6000 @chrisjl#2655 will always yield the same result. - Running .love @chrisjl#2655 @joe#6000 will yield the same result as before. - """ - if ( - Lovefest.role_id not in [role.id for role in who.roles] - or (whom is not None and Lovefest.role_id not in [role.id for role in whom.roles]) - ): - raise BadArgument( - "This command can only be ran against members with the lovefest role! " - "This role be can assigned by running " - f"`{Client.prefix}lovefest sub` in <#{Channels.community_bot_commands}>." - ) - - if whom is None: - whom = ctx.author - - def normalize(arg: Member) -> Coroutine: - # This has to be done manually to be applied to usernames - return clean_content(escape_markdown=True).convert(ctx, str(arg)) - - # Sort to ensure same result for same input, regardless of order - who, whom = sorted([await normalize(arg) for arg in (who, whom)]) - - # Hash inputs to guarantee consistent results (hashing algorithm choice arbitrary) - # - # hashlib is used over the builtin hash() to guarantee same result over multiple runtimes - m = hashlib.sha256(who.encode() + whom.encode()) - # Mod 101 for [0, 100] - love_percent = sum(m.digest()) % 101 - - # We need the -1 due to how bisect returns the point - # see the documentation for further detail - # https://docs.python.org/3/library/bisect.html#bisect.bisect - index = bisect.bisect(LOVE_DATA, (love_percent,)) - 1 - # We already have the nearest "fit" love level - # We only need the dict, so we can ditch the first element - _, data = LOVE_DATA[index] - - status = random.choice(data["titles"]) - embed = discord.Embed( - title=status, - description=f"{who} \N{HEAVY BLACK HEART} {whom} scored {love_percent}%!\n\u200b", - color=discord.Color.dark_magenta() - ) - embed.add_field( - name="A letter from Dr. Love:", - value=data["text"] - ) - embed.set_footer(text=f"You can unsubscribe from lovefest by using {Client.prefix}lovefest unsub") - - await ctx.send(embed=embed) - - -def setup(bot: Bot) -> None: - """Load the Love calculator Cog.""" - bot.add_cog(LoveCalculator()) diff --git a/bot/exts/valentines/movie_generator.py b/bot/exts/valentines/movie_generator.py deleted file mode 100644 index d2dc8213..00000000 --- a/bot/exts/valentines/movie_generator.py +++ /dev/null @@ -1,67 +0,0 @@ -import logging -import random -from os import environ - -import discord -from discord.ext import commands - -from bot.bot import Bot - -TMDB_API_KEY = environ.get("TMDB_API_KEY") - -log = logging.getLogger(__name__) - - -class RomanceMovieFinder(commands.Cog): - """A Cog that returns a random romance movie suggestion to a user.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @commands.command(name="romancemovie") - async def romance_movie(self, ctx: commands.Context) -> None: - """Randomly selects a romance movie and displays information about it.""" - # Selecting a random int to parse it to the page parameter - random_page = random.randint(0, 20) - # TMDB api params - params = { - "api_key": TMDB_API_KEY, - "language": "en-US", - "sort_by": "popularity.desc", - "include_adult": "false", - "include_video": "false", - "page": random_page, - "with_genres": "10749" - } - # The api request url - request_url = "https://api.themoviedb.org/3/discover/movie" - async with self.bot.http_session.get(request_url, params=params) as resp: - # Trying to load the json file returned from the api - try: - data = await resp.json() - # Selecting random result from results object in the json file - selected_movie = random.choice(data["results"]) - - embed = discord.Embed( - title=f":sparkling_heart: {selected_movie['title']} :sparkling_heart:", - description=selected_movie["overview"], - ) - embed.set_image(url=f"http://image.tmdb.org/t/p/w200/{selected_movie['poster_path']}") - embed.add_field(name="Release date :clock1:", value=selected_movie["release_date"]) - embed.add_field(name="Rating :star2:", value=selected_movie["vote_average"]) - embed.set_footer(text="This product uses the TMDb API but is not endorsed or certified by TMDb.") - embed.set_thumbnail(url="https://i.imgur.com/LtFtC8H.png") - await ctx.send(embed=embed) - except KeyError: - warning_message = ( - "A KeyError was raised while fetching information on the movie. The API service" - " could be unavailable or the API key could be set incorrectly." - ) - embed = discord.Embed(title=warning_message) - log.warning(warning_message) - await ctx.send(embed=embed) - - -def setup(bot: Bot) -> None: - """Load the Romance movie Cog.""" - bot.add_cog(RomanceMovieFinder(bot)) diff --git a/bot/exts/valentines/myvalenstate.py b/bot/exts/valentines/myvalenstate.py deleted file mode 100644 index 52a61011..00000000 --- a/bot/exts/valentines/myvalenstate.py +++ /dev/null @@ -1,82 +0,0 @@ -import collections -import json -import logging -from pathlib import Path -from random import choice - -import discord -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Colours - -log = logging.getLogger(__name__) - -STATES = json.loads(Path("bot/resources/valentines/valenstates.json").read_text("utf8")) - - -class MyValenstate(commands.Cog): - """A Cog to find your most likely Valentine's vacation destination.""" - - def levenshtein(self, source: str, goal: str) -> int: - """Calculates the Levenshtein Distance between source and goal.""" - if len(source) < len(goal): - return self.levenshtein(goal, source) - if len(source) == 0: - return len(goal) - if len(goal) == 0: - return len(source) - - pre_row = list(range(0, len(source) + 1)) - for i, source_c in enumerate(source): - cur_row = [i + 1] - for j, goal_c in enumerate(goal): - if source_c != goal_c: - cur_row.append(min(pre_row[j], pre_row[j + 1], cur_row[j]) + 1) - else: - cur_row.append(min(pre_row[j], pre_row[j + 1], cur_row[j])) - pre_row = cur_row - return pre_row[-1] - - @commands.command() - async def myvalenstate(self, ctx: commands.Context, *, name: str = None) -> None: - """Find the vacation spot(s) with the most matching characters to the invoking user.""" - eq_chars = collections.defaultdict(int) - if name is None: - author = ctx.author.name.lower().replace(" ", "") - else: - author = name.lower().replace(" ", "") - - for state in STATES.keys(): - lower_state = state.lower().replace(" ", "") - eq_chars[state] = self.levenshtein(author, lower_state) - - matches = [x for x, y in eq_chars.items() if y == min(eq_chars.values())] - valenstate = choice(matches) - matches.remove(valenstate) - - embed_title = "But there are more!" - if len(matches) > 1: - leftovers = f"{', '.join(matches[:-2])}, and {matches[-1]}" - embed_text = f"You have {len(matches)} more matches, these being {leftovers}." - elif len(matches) == 1: - embed_title = "But there's another one!" - embed_text = f"You have another match, this being {matches[0]}." - else: - embed_title = "You have a true match!" - embed_text = "This state is your true Valenstate! There are no states that would suit" \ - " you better" - - embed = discord.Embed( - title=f"Your Valenstate is {valenstate} \u2764", - description=STATES[valenstate]["text"], - colour=Colours.pink - ) - embed.add_field(name=embed_title, value=embed_text) - embed.set_image(url=STATES[valenstate]["flag"]) - await ctx.send(embed=embed) - - -def setup(bot: Bot) -> None: - """Load the Valenstate Cog.""" - bot.add_cog(MyValenstate()) diff --git a/bot/exts/valentines/pickuplines.py b/bot/exts/valentines/pickuplines.py deleted file mode 100644 index 00741a72..00000000 --- a/bot/exts/valentines/pickuplines.py +++ /dev/null @@ -1,41 +0,0 @@ -import logging -import random -from json import loads -from pathlib import Path - -import discord -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Colours - -log = logging.getLogger(__name__) - -PICKUP_LINES = loads(Path("bot/resources/valentines/pickup_lines.json").read_text("utf8")) - - -class PickupLine(commands.Cog): - """A cog that gives random cheesy pickup lines.""" - - @commands.command() - async def pickupline(self, ctx: commands.Context) -> None: - """ - Gives you a random pickup line. - - Note that most of them are very cheesy. - """ - random_line = random.choice(PICKUP_LINES["lines"]) - embed = discord.Embed( - title=":cheese: Your pickup line :cheese:", - description=random_line["line"], - color=Colours.pink - ) - embed.set_thumbnail( - url=random_line.get("image", PICKUP_LINES["placeholder"]) - ) - await ctx.send(embed=embed) - - -def setup(bot: Bot) -> None: - """Load the Pickup lines Cog.""" - bot.add_cog(PickupLine()) diff --git a/bot/exts/valentines/savethedate.py b/bot/exts/valentines/savethedate.py deleted file mode 100644 index ffe559d6..00000000 --- a/bot/exts/valentines/savethedate.py +++ /dev/null @@ -1,38 +0,0 @@ -import logging -import random -from json import loads -from pathlib import Path - -import discord -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Colours - -log = logging.getLogger(__name__) - -HEART_EMOJIS = [":heart:", ":gift_heart:", ":revolving_hearts:", ":sparkling_heart:", ":two_hearts:"] - -VALENTINES_DATES = loads(Path("bot/resources/valentines/date_ideas.json").read_text("utf8")) - - -class SaveTheDate(commands.Cog): - """A cog that gives random suggestion for a Valentine's date.""" - - @commands.command() - async def savethedate(self, ctx: commands.Context) -> None: - """Gives you ideas for what to do on a date with your valentine.""" - random_date = random.choice(VALENTINES_DATES["ideas"]) - emoji_1 = random.choice(HEART_EMOJIS) - emoji_2 = random.choice(HEART_EMOJIS) - embed = discord.Embed( - title=f"{emoji_1}{random_date['name']}{emoji_2}", - description=f"{random_date['description']}", - colour=Colours.pink - ) - await ctx.send(embed=embed) - - -def setup(bot: Bot) -> None: - """Load the Save the date Cog.""" - bot.add_cog(SaveTheDate()) diff --git a/bot/exts/valentines/valentine_zodiac.py b/bot/exts/valentines/valentine_zodiac.py deleted file mode 100644 index 243f156e..00000000 --- a/bot/exts/valentines/valentine_zodiac.py +++ /dev/null @@ -1,146 +0,0 @@ -import calendar -import json -import logging -import random -from datetime import datetime -from pathlib import Path -from typing import Union - -import discord -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Colours - -log = logging.getLogger(__name__) - -LETTER_EMOJI = ":love_letter:" -HEART_EMOJIS = [":heart:", ":gift_heart:", ":revolving_hearts:", ":sparkling_heart:", ":two_hearts:"] - - -class ValentineZodiac(commands.Cog): - """A Cog that returns a counter compatible zodiac sign to the given user's zodiac sign.""" - - def __init__(self): - self.zodiacs, self.zodiac_fact = self.load_comp_json() - - @staticmethod - def load_comp_json() -> tuple[dict, dict]: - """Load zodiac compatibility from static JSON resource.""" - explanation_file = Path("bot/resources/valentines/zodiac_explanation.json") - compatibility_file = Path("bot/resources/valentines/zodiac_compatibility.json") - - zodiac_fact = json.loads(explanation_file.read_text("utf8")) - - for zodiac_data in zodiac_fact.values(): - zodiac_data["start_at"] = datetime.fromisoformat(zodiac_data["start_at"]) - zodiac_data["end_at"] = datetime.fromisoformat(zodiac_data["end_at"]) - - zodiacs = json.loads(compatibility_file.read_text("utf8")) - - return zodiacs, zodiac_fact - - def generate_invalidname_embed(self, zodiac: str) -> discord.Embed: - """Returns error embed.""" - embed = discord.Embed() - embed.color = Colours.soft_red - error_msg = f"**{zodiac}** is not a valid zodiac sign, here is the list of valid zodiac signs.\n" - names = list(self.zodiac_fact) - middle_index = len(names) // 2 - first_half_names = ", ".join(names[:middle_index]) - second_half_names = ", ".join(names[middle_index:]) - embed.description = error_msg + first_half_names + ",\n" + second_half_names - log.info("Invalid zodiac name provided.") - return embed - - def zodiac_build_embed(self, zodiac: str) -> discord.Embed: - """Gives informative zodiac embed.""" - zodiac = zodiac.capitalize() - embed = discord.Embed() - embed.color = Colours.pink - if zodiac in self.zodiac_fact: - log.trace("Making zodiac embed.") - embed.title = f"__{zodiac}__" - embed.description = self.zodiac_fact[zodiac]["About"] - embed.add_field(name="__Motto__", value=self.zodiac_fact[zodiac]["Motto"], inline=False) - embed.add_field(name="__Strengths__", value=self.zodiac_fact[zodiac]["Strengths"], inline=False) - embed.add_field(name="__Weaknesses__", value=self.zodiac_fact[zodiac]["Weaknesses"], inline=False) - embed.add_field(name="__Full form__", value=self.zodiac_fact[zodiac]["full_form"], inline=False) - embed.set_thumbnail(url=self.zodiac_fact[zodiac]["url"]) - else: - embed = self.generate_invalidname_embed(zodiac) - log.trace("Successfully created zodiac information embed.") - return embed - - def zodiac_date_verifier(self, query_date: datetime) -> str: - """Returns zodiac sign by checking date.""" - for zodiac_name, zodiac_data in self.zodiac_fact.items(): - if zodiac_data["start_at"].date() <= query_date.date() <= zodiac_data["end_at"].date(): - log.trace("Zodiac name sent.") - return zodiac_name - - @commands.group(name="zodiac", invoke_without_command=True) - async def zodiac(self, ctx: commands.Context, zodiac_sign: str) -> None: - """Provides information about zodiac sign by taking zodiac sign name as input.""" - final_embed = self.zodiac_build_embed(zodiac_sign) - await ctx.send(embed=final_embed) - log.trace("Embed successfully sent.") - - @zodiac.command(name="date") - async def date_and_month(self, ctx: commands.Context, date: int, month: Union[int, str]) -> None: - """Provides information about zodiac sign by taking month and date as input.""" - if isinstance(month, str): - month = month.capitalize() - try: - month = list(calendar.month_abbr).index(month[:3]) - log.trace("Valid month name entered by user") - except ValueError: - log.info("Invalid month name entered by user") - await ctx.send(f"Sorry, but `{month}` is not a valid month name.") - return - if (month == 1 and 1 <= date <= 19) or (month == 12 and 22 <= date <= 31): - zodiac = "capricorn" - final_embed = self.zodiac_build_embed(zodiac) - else: - try: - zodiac_sign_based_on_date = self.zodiac_date_verifier(datetime(2020, month, date)) - log.trace("zodiac sign based on month and date received.") - except ValueError as e: - final_embed = discord.Embed() - final_embed.color = Colours.soft_red - final_embed.description = f"Zodiac sign could not be found because.\n```\n{e}\n```" - log.info(f"Error in 'zodiac date' command:\n{e}.") - else: - final_embed = self.zodiac_build_embed(zodiac_sign_based_on_date) - - await ctx.send(embed=final_embed) - log.trace("Embed from date successfully sent.") - - @zodiac.command(name="partnerzodiac", aliases=("partner",)) - async def partner_zodiac(self, ctx: commands.Context, zodiac_sign: str) -> None: - """Provides a random counter compatible zodiac sign to the given user's zodiac sign.""" - embed = discord.Embed() - embed.color = Colours.pink - zodiac_check = self.zodiacs.get(zodiac_sign.capitalize()) - if zodiac_check: - compatible_zodiac = random.choice(self.zodiacs[zodiac_sign.capitalize()]) - emoji1 = random.choice(HEART_EMOJIS) - emoji2 = random.choice(HEART_EMOJIS) - embed.title = "Zodiac Compatibility" - embed.description = ( - f"{zodiac_sign.capitalize()}{emoji1}{compatible_zodiac['Zodiac']}\n" - f"{emoji2}Compatibility meter : {compatible_zodiac['compatibility_score']}{emoji2}" - ) - embed.add_field( - name=f"A letter from Dr.Zodiac {LETTER_EMOJI}", - value=compatible_zodiac["description"] - ) - else: - embed = self.generate_invalidname_embed(zodiac_sign) - await ctx.send(embed=embed) - log.trace("Embed from date successfully sent.") - - -def setup(bot: Bot) -> None: - """Load the Valentine zodiac Cog.""" - bot.add_cog(ValentineZodiac()) diff --git a/bot/exts/valentines/whoisvalentine.py b/bot/exts/valentines/whoisvalentine.py deleted file mode 100644 index 211b1f27..00000000 --- a/bot/exts/valentines/whoisvalentine.py +++ /dev/null @@ -1,49 +0,0 @@ -import json -import logging -from pathlib import Path -from random import choice - -import discord -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Colours - -log = logging.getLogger(__name__) - -FACTS = json.loads(Path("bot/resources/valentines/valentine_facts.json").read_text("utf8")) - - -class ValentineFacts(commands.Cog): - """A Cog for displaying facts about Saint Valentine.""" - - @commands.command(aliases=("whoisvalentine", "saint_valentine")) - async def who_is_valentine(self, ctx: commands.Context) -> None: - """Displays info about Saint Valentine.""" - embed = discord.Embed( - title="Who is Saint Valentine?", - description=FACTS["whois"], - color=Colours.pink - ) - embed.set_thumbnail( - url="https://upload.wikimedia.org/wikipedia/commons/thumb/f/f1/Saint_Valentine_-_" - "facial_reconstruction.jpg/1024px-Saint_Valentine_-_facial_reconstruction.jpg" - ) - - await ctx.send(embed=embed) - - @commands.command() - async def valentine_fact(self, ctx: commands.Context) -> None: - """Shows a random fact about Valentine's Day.""" - embed = discord.Embed( - title=choice(FACTS["titles"]), - description=choice(FACTS["text"]), - color=Colours.pink - ) - - await ctx.send(embed=embed) - - -def setup(bot: Bot) -> None: - """Load the Who is Valentine Cog.""" - bot.add_cog(ValentineFacts()) diff --git a/bot/resources/holidays/valentines/bemyvalentine_valentines.json b/bot/resources/holidays/valentines/bemyvalentine_valentines.json new file mode 100644 index 00000000..7d5d3705 --- /dev/null +++ b/bot/resources/holidays/valentines/bemyvalentine_valentines.json @@ -0,0 +1,45 @@ +{ + "valentine_poems": [ + + "If you were my rose,\nthen I'd be your sun,\npainting you rainbows when the rains come.\nI'd change my orbit to banish the night,\nas to keep you in my nurturing light.", + "If you were my world, then I'd be your moon,\nyour silent protector, a night-light in the gloom.\nOur fates intertwined, two bodies in motion through time and space,\nour dance of devotion.", + "If you were my island, then I'd be your sea,\ncaressing your shores, soft and gentle I'd be.\nMy tidal embrace would leave gifts on your sands,\nbut by current and storm, I'd ward your gentle lands.", + "If you were love's promise, then I would be time,\nyour constant companion till stars align.\nAnd though we are mere mortals,\ntrue love is divine,and my devotion eternal,\nto my one valentine.", + "Have I told you,\nValentine, That I’m all wrapped up in you?\nMy feelings for you bring to me A joy I never knew.\n \n You light up everything for me; In my heart you shine;\nIlluminating my whole life,\nMy darling Valentine.", + "My days are filled with yearning;\nMy nights are full of dreams.\nI’m always thinking of you;\nI’m in a trance, it seems.\n\n You’re all I ever wanted;\nI wish you could be mine;\nAnd so I have to ask you: Will you be my Valentine?", + "My Valentine, I love just you;\nMy devotion I declare.\nI’ll spend my life looking for ways To show you that I care.\n\nPlease say you feel the same for me;\nSay you’ll be forever mine;\nWe’ll share a life of happiness,\nMy treasured Valentine.", + "Every day with you is Valentine's Day, my love.\nEvery day is filled with romance, with love, with sharing and caring.\nEvery day I am reminded how blessed I am to have you as my,\nValentine, my sweetheart, my lover, my friend, my playmate, my companion.\nNo Valentine card, no words at all could express how much I love You,\nhow ecstatic I feel to know that you are mine.\nMy Valentine, every day,\nI'll try to show you that every day I love you more.", + "I lucked out when I met you, sweet thing.\nYou've brought richness to each day I exist.\nYou fill my days with the excitement of love,\nAnd you have since the moment we kissed.\nSo I celebrate Valentine's Day with you,\nWith a love that will always stay fresh and new.", + "You are my everything, Valentine.\nAs a desert creature longs for water, my thirst for you can never be slaked.\nIn a ho-hum day dragging on, thoughts of you bring excitement, joy and pleasure.\nAs a child opens the birthday gift he most wanted,\nI see everything I want in you.\nYou are my everything, Valentine.", + "My love for you is like the raging sea,\nSo powerful and deep it will forever be.\nThrough storm, wind, and heavy rain, It will withstand every pain.\nOur hearts are so pure and love so sweet.\nI love you more with every heartbeat!", + "A million stars up in the sky.\nOne shines brighter - I can't deny.\nA love so precious, a love so true,\na love that comes from me to you.\nThe angels sing when you are near.\nWithin your arms I have nothing to fear.\nYou always know just what to say.\nJust talking to you makes my day.\nI love you, honey, with all of my heart.\nTogether forever and never to part.", + "What do I do,\nWhen I'm still in love with you?\nYou walked away, Cause you didn't want to stay.\nYou broke my heart, you tore me apart.\nEvery day I wait for you, Telling myself our love was true.\nBut when you don't show, more tears start to flow.\nThat's when I know I have to let go.", + "When I say I love you, please believe it's true.\nWhen I say forever, know I'll never leave you.\nWhen I say goodbye, promise me you won't cry,\nBecause the day I'll be saying that will be the day I die.", + "Beauty isn't seen by eyes.\nIt's felt by hearts,\nRecognized by souls,\nIn the presence of love.", + "L is for \"laughter\" we had along the way.\nO is for \"optimism\" you gave me every day.\nV is for \"value\" of being my best friend.\nE is for \"eternity,\" a love that has no end.", + "If roses were red and violets could be blue,\nI'd take us away to a place just for two.\nYou'd see my true colors and all that I felt.\nI'd see that you could love me and nobody else.\nWe'd build ourselves a fortress where we'd run and play.\nYou'd be mine and I'd be yours till our dying day.\nThen I wake and realize you were never here.\nIt's all just my thoughts, my dreams, my hopes...\nBut now it's only tears!" + + ], + "valentine_compliments": [ + + "To the love of my life. I’m the luckiest person on Earth because I have you! I adore you! You’ve taught me that the best thing to hold onto in life is each other. You are my sweetheart, you are my life, you are my everything.", + "It was a million tiny little things that, when you added them all up, they meant we were supposed to be together.", + "When you smile, the whole world stops and stares for a while, cause you're amazing, just the way you are.", + "Take love, multiply it by infinity and take it to the depths of forever... and you still have only a glimpse of how I feel for you.", + "When you kiss me, the world just melts away. When you hold me in your arms, I feel safe. Being in love with you has made me see how wonderful life can be. I love you.", + "No matter how dark the days get, you still know how to make me smile. Even after all this time, you still take my breath away.", + "I don't know what my future holds, but I know I want you in it. I would travel to the moon and back just to see you smile.", + "I may not always say it, but know that with every breath in my body and every beat of my heart I know I will always love you.", + "Darling I will be loving you till we're 70. And baby my heart could still fall as hard at 23. And I'm thinking about how people fall in love in mysterious ways. Maybe just the touch of a hand. Oh me, I fall in love with you every single day. And I just wanna tell you I am. So honey now. Take me into your loving arms. Kiss me under the light of a thousand stars. Place your head on my beating heart. I'm thinking out loud. Maybe we found love right where we are.", + "I love you. I knew it the minute I met you. I'm sorry it took so long for me to catch up. I just got stuck.", + "You are truly beautiful from within. I am serious! It's not just about how pretty you are (which you are, of course), but you have a beautiful heart.", + "If you could see yourself through my eyes, you would know how much I love you. You hold a very special place in my heart! I will love you forever!", + "I don’t need a thousand reasons to feel special. All I need is you to live in this world. You are the sunshine of my life.", + "I wish to be everything that brings a smile on your face and happiness in your heart. I want to love you like no else ever did!", + "Every morning of my life gives you a new reason to love you and to appreciate you for what you’ve given me. You are the one that holds the key to my heart!", + "Each time I look at you, I just smile to myself and think, ‘I certainly could not have done better’. You are perfect the way you are. I love you honey.", + "Look at the computer keyboard, U and I were placed side by side. That’s how the alphabets should be arranged because my love will never cease to exist as long as it’s you and me." + + ] + +} diff --git a/bot/resources/holidays/valentines/date_ideas.json b/bot/resources/holidays/valentines/date_ideas.json new file mode 100644 index 00000000..995f14bb --- /dev/null +++ b/bot/resources/holidays/valentines/date_ideas.json @@ -0,0 +1,125 @@ +{ + "ideas": [ + { + "name": "Chick flick marathon", + "description": "This is a very basic yet highly romantic way of spending the day with your partner. Take a few days to prepare the right playlist and create a romantic atmosphere at home. You can order out some food, open a bottle of wine and cuddle up in front of the TV." + }, + + { + "name": "Recreate your first date", + "description": "Rated pretty high on the “romantic gestures scale,” this is guaranteed to impress your significant other. It requires a good memory and a bit of work to make it just right, but it is well worth it. Walk down the same streets where you first kissed and have a couple of drinks in that old coffee shop where you had your first drinks together. Don’t be afraid to spend a bit extra and add a little romantic gift into the mix." + }, + { + "name": "Cook for your loved one", + "description": "Start researching good recipes for a romantic dinner for two, get the right ingredients and prepare a couple of practice dinners to make sure you’ve got your technique and presentation down pat. Cooking for someone can be a big turn on and you can create some incredible meals without spending too much money. Take it up a notch by dressing classy, decorating your dining room and presenting your partner with a printed menu." + }, + { + "name": "Organize your very own ancient Greek party", + "description": "Here’s another one of those creative date ideas for the stay-at-home couple. The ancient Greek private party can be a very fun and erotic experience. You can decorate by using big bowls full of grapes, spreading some white sheets all over the place, placing some plastic vines here and there, putting up a few posters depicting Greek parties and having plenty of wine lying around. Wear nothing but light sheets or costumes and channel some of that hot-blooded Greek spirit." + }, + { + "name": "A romantic weekend getaway in the mountains", + "description": "For those looking for a change of scenery and an escape from the busy city, there is nothing better than a quiet, romantic weekend in the mountains. There are plenty of fun activities like skiing that will keep you active. You can have fun making a snowman or engaging in a snowball fight, and, of course, there is plenty of privacy and great room service waiting for you back at your room." + }, + { + "name": "Fun day at the shooting range", + "description": "A bit unconventional but an incredibly fun and exciting date that will get your blood pumping and put a huge smile on your faces. Try out a number of guns and have a bit of a competition. Some outdoor ranges have fully automatic rifles, which are a blast to shoot." + }, + { + "name": "Rent an expensive sports car for a day", + "description": "Don’t be afraid to live large from time to time—even if you can’t afford the glamorous lifestyle of the stars, you can most definitely play pretend for a day. Put on some classy clothes and drive around town in a rented sports car. The quick acceleration and high speed are sure to provide an exhilarating experience. " + }, + { + "name": "Go on a shopping spree together", + "description": "Very few things can elicit such a huge dopamine rush as a good old shopping spree. Get some new sexy lingerie, pretty shoes, a nice shirt and tie, a couple of new video games or whatever else you need or want. This is a unique chance to bond, have fun and get some stuff that you’ve been waiting to buy for a while now." + }, + { + "name": "Hit the clubs", + "description": "For all the party animals out there, one of the best date ideas is to go out drinking, dancing, and just generally enjoying the night life. Visit a few good clubs, then go to an after-party and keep that party spirit going for as long as you can." + }, + { + "name": "Spend the day driving around the city and visiting new places", + "description": "This one is geared towards couples who have been together for a year or two and want to experience a few new things together. Visit a few cool coffee places on the other side of town, check out interesting restaurants you’ve never been to, and consider going to see a play or having fun at a comedy club on open mic night." + }, + { + "name": "Wine and chocolates at sunset", + "description": "Pick out a romantic location, such as a camping spot on a hill overlooking the city or a balcony in a restaurant with a nice view, open a bottle of wine and a box of chocolates and wait for that perfect moment when the sky turns fiery red to embrace and share a passionate kiss." + }, + { + "name": "Ice skating", + "description": "There is something incredibly fun about ice skating that brings people closer together and just keeps you laughing (maybe it’s all the falling and clinging to the other person for dear life). You can have some great fun and then move on to a more private location for some alone time." + }, + { + "name": "Model clothes for each other", + "description": "This one goes well when combined with a shopping spree, but you can just get a bunch of your clothes—old and new—from the closet, set up a catwalk area and then try on different combinations. You can be stylish, funny, handsome and sexy. It’s a great after-dinner show and a good way to transition into a more intimate atmosphere." + }, + { + "name": "Dance the night away", + "description": "If you and your significant other are relatively good dancers, or if you simply enjoy moving your body to the rhythm of the music, then a night at salsa club or similar venue is the perfect thing for you. Alternatively, you can set up dance floor at home, play your favorite music, have a few drinks and dance like there is no tomorrow." + }, + { + "name": "Organize a nature walk", + "description": "Being outside has many health benefits, but what you are going for is the beautiful view, seclusion, and the thrill of engaging in some erotic behavior out in the open. You can rent a cottage far from the city, bring some food and drinks, and explore the wilderness. This is nice way to spice things up a bit and get away from the loud and busy city life." + }, + { + "name": "Travel abroad", + "description": "This takes a bit of planning in advance and may be a bit costly, but if you can afford it, there are very few things that can match a trip to France, Italy, Egypt, Turkey, Greece, or a number of other excellent locations." + }, + { + "name": "Go on a hot-air balloon ride", + "description": "These are very fun and romantic—you get an incredible view, get to experience the thrill of flying, and you’ve got enough room for a romantic dinner and some champagne. Just be sure to wear warm clothes, it can get pretty cold high up in the air." + }, + { + "name": "A relaxing day at the spa", + "description": "Treat your body, mind and senses to a relaxing day at the spa. You and your partner will feel fresh, comfortable, relaxed, and sexy as hell—a perfect date for the more serious couples who don’t get to spend as much time with each other as they’d like." + }, + { + "name": "Fun times at a karaoke bar", + "description": "A great choice for couples celebrating their first Valentine’s Day together—it’s fairly informal and inexpensive, yet incredibly fun and allows for deeper bonding. Once you have a few drinks in your system and come to terms with the fact that you are making a complete fool of yourself, you’ll have the time of your life!" + }, + { + "name": "Horseback riding", + "description": "Horseback riding is incredibly fun, especially if you’ve never done it before. And what girl doesn’t dream of a prince coming to take her on an adventure on his noble steed? It evokes a sense of nobility and is a very good bonding experience." + }, + { + "name": "Plan a fun date night with other couples", + "description": "Take a break and rent a cabin in the woods, go to a mountain resort, a couple’s retreat, or just organize a huge date night at someone’s place and hang out with other couples. This is a great option for couples who have spent at least one Valentine’s Day together and allows you to customize your experience to suit your needs. Also, you can always retire early and get some alone time with your partner if you so desire." + }, + { + "name": "Go to a concert", + "description": "There are a whole bunch of things happening around Valentine’s Day, so go online and check out what’s happening near you. You’ll surely be able to find tickets for a cool concert or some type of festival with live music." + }, + { + "name": "Fancy night on the town", + "description": "Buy some elegant new clothes, rent a limo for the night and go to a nice restaurant, followed by a jazz club or gallery exhibition. Walk tall, make a few sarcastic quips, and have a few laughs with your partner while letting your inner snob take charge for a few hours." + }, + { + "name": "Live out a James Bond film at a casino", + "description": "A beautiful lady in a simple yet sensual, form-fitting, black dress, and a strong and handsome, if somewhat stern-looking man in a fine suit walk up to a roulette table with drinks in hand and place bets at random as they smile at each other seductively. This is a scenario most of us wish to play out, but rarely get a chance. It can be a bit costly, but this is one of the most incredibly adventurous and romantic date ideas." + }, + { + "name": "Go bungee jumping", + "description": "People in long-term relationships often talk about things like keeping a relationship fun and exciting, doing new things together, trusting each other and using aphrodisiacs. Well, bungee jumping is a fun, exhilarating activity you can both enjoy; it requires trust and the adrenaline rush you get from it is better than any aphrodisiac out there. Just saying, give it a shot and you won’t regret it. " + }, + { + "name": "Play some sports", + "description": "Some one-on-one basketball, a soccer match against another couple, a bit of tennis, or even something as simple as a table tennis tournament (make it fun by stripping off items of clothing when you lose a game). You can combine this with date idea #13 and paint team uniforms on each other and play in the nude." + }, + { + "name": "Take skydiving lessons", + "description": "An adrenaline-filled date, skydiving is sure to get your heart racing like crazy and leave you with a goofy grin for the rest of the day. You can offset all the excitement by ending the day with a quiet dinner at home." + }, + { + "name": "Go for some paintball", + "description": "Playing war games is an excellent way to get your body moving, focus on some of that hand-eye-coordination, and engage your brain in coming up with tactical solutions in the heat of the moment. It is also a great bonding experience, adrenaline-fueled fun, and role-playing all wrapped into one. And when you get back home, you can always act out the wounded soldier scenario." + }, + { + "name": "Couples’ Yoga", + "description": "Getting up close, hot, and sweaty? Sounds like a Valentine’s Day movie to me. By signing up with your partner for a couples’ yoga class, you can sneak in a workout while getting some face-to-face time with your date.This type of yoga focuses on poses that can be done with a partner, such as back-to-back bends, assisted stretches, and face-to-face breathing exercises. By working out together, you strengthen your bond while stretching away the stress of the week. Finish the date off by heading to the juice bar for a smoothie, or indulging in healthy salads for two. Expect to spend around $35 per person, or approximately $50 to $60 per couple." + }, + { + "name": "Volunteer Together", + "description": "Getting your hands dirty for a good cause might not be the first thing that pops into your mind when you think “romance,” but there’s something to be said for a date that gives you a glimpse of your partner’s charitable side. Consider volunteering at an animal rescue, where you might be able to play with pups or help a few lovebirds pick out their perfect pet. Or, sign up to visit the elderly at a care center, where you can be a completely different kind of Valentine for someone in need." + } + ] +} diff --git a/bot/resources/holidays/valentines/love_matches.json b/bot/resources/holidays/valentines/love_matches.json new file mode 100644 index 00000000..7df2dbda --- /dev/null +++ b/bot/resources/holidays/valentines/love_matches.json @@ -0,0 +1,58 @@ +{ + "0": { + "titles": [ + "\ud83d\udc94 There's no real connection between you two \ud83d\udc94" + ], + "text": "The chance of this relationship working out is really low. You can get it to work, but with high costs and no guarantee of working out. Do not sit back, spend as much time together as possible, talk a lot with each other to increase the chances of this relationship's survival." + }, + "5": { + "titles": [ + "\ud83d\udc99 A small acquaintance \ud83d\udc99" + ], + "text": "There might be a chance of this relationship working out somewhat well, but it is not very high. With a lot of time and effort you'll get it to work eventually, however don't count on it. It might fall apart quicker than you'd expect." + }, + "20": { + "titles": [ + "\ud83d\udc9c You two seem like casual friends \ud83d\udc9c" + ], + "text": "The chance of this relationship working is not very high. You both need to put time and effort into this relationship, if you want it to work out well for both of you. Talk with each other about everything and don't lock yourself up. Spend time together. This will improve the chances of this relationship's survival by a lot." + }, + "30": { + "titles": [ + "\ud83d\udc97 You seem like you are good friends \ud83d\udc97" + ], + "text": "The chance of this relationship working is not very high, but its not that low either. If you both want this relationship to work, and put time and effort into it, meaning spending time together, talking to each other etc., than nothing shall stand in your way." + }, + "45": { + "titles": [ + "\ud83d\udc98 You two are really close aren't you? \ud83d\udc98" + ], + "text": "Your relationship has a reasonable amount of working out. But do not overestimate yourself there. Your relationship will suffer good and bad times. Make sure to not let the bad times destroy your relationship, so do not hesitate to talk to each other, figure problems out together etc." + }, + "60": { + "titles": [ + "\u2764 So when will you two go on a date? \u2764" + ], + "text": "Your relationship will most likely work out. It won't be perfect and you two need to spend a lot of time together, but if you keep on having contact, the good times in your relationship will outweigh the bad ones." + }, + "80": { + "titles": [ + "\ud83d\udc95 Aww look you two fit so well together \ud83d\udc95" + ], + "text": "Your relationship will most likely work out well. Don't hesitate on making contact with each other though, as your relationship might suffer from a lack of time spent together. Talking with each other and spending time together is key." + }, + "95": { + "titles": [ + "\ud83d\udc96 Love is in the air \ud83d\udc96", + "\ud83d\udc96 Planned your future yet? \ud83d\udc96" + ], + "text": "Your relationship will most likely work out perfect. This doesn't mean thought that you don't need to put effort into it. Talk to each other, spend time together, and you two won't have a hard time." + }, + "100": { + "titles": [ + "\ud83d\udc9b When will you two marry? \ud83d\udc9b", + "\ud83d\udc9b Now kiss already \ud83d\udc9b" + ], + "text": "You two will most likely have the perfect relationship. But don't think that this means you don't have to do anything for it to work. Talking to each other and spending time together is key, even in a seemingly perfect relationship." + } +} diff --git a/bot/resources/holidays/valentines/pickup_lines.json b/bot/resources/holidays/valentines/pickup_lines.json new file mode 100644 index 00000000..eb01290f --- /dev/null +++ b/bot/resources/holidays/valentines/pickup_lines.json @@ -0,0 +1,97 @@ +{ + "placeholder": "https://i.imgur.com/BB52sxj.jpg", + "lines": [ + { + "line": "Hey baby are you allergic to dairy cause I **laktose** clothes you're wearing", + "image": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8f/Cheese_%281105942243%29.jpg/800px-Cheese_%281105942243%29.jpg" + }, + { + "line": "I’m not a photographer, but I can **picture** me and you together.", + "image": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/57/2016_Minolta_Dynax_404si.JPG/220px-2016_Minolta_Dynax_404si.JPG" + }, + { + "line": "I seem to have lost my phone number. Can I have yours?" + }, + { + "line": "Are you French? Because **Eiffel** for you.", + "image": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/85/Tour_Eiffel_Wikimedia_Commons_%28cropped%29.jpg/240px-Tour_Eiffel_Wikimedia_Commons_%28cropped%29.jpg" + }, + { + "line": "Hey babe are you a cat? Because I'm **feline** a connection between us.", + "image": "https://upload.wikimedia.org/wikipedia/commons/4/4d/Cat_November_2010-1a.jpg" + }, + { + "line": "Baby, life without you is like a broken pencil... **pointless**.", + "image": "https://upload.wikimedia.org/wikipedia/commons/0/08/Pencils_hb.jpg" + }, + { + "line": "Babe did it hurt when you fell from heaven?" + }, + { + "line": "If I could rearrange the alphabet, I would put **U** and **I** together.", + "image": "https://images-na.ssl-images-amazon.com/images/I/51wJaFX%2BnGL._SX425_.jpg" + }, + { + "line": "Is your name Google? Because you're everything I'm searching for.", + "image": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/53/Google_%22G%22_Logo.svg/1024px-Google_%22G%22_Logo.svg.png" + }, + { + "line": "Are you from Starbucks? Because I like you a **latte**.", + "image": "https://upload.wikimedia.org/wikipedia/en/thumb/d/d3/Starbucks_Corporation_Logo_2011.svg/1200px-Starbucks_Corporation_Logo_2011.svg.png" + }, + { + "line": "Are you a banana? Because I find you **a peeling**.", + "image": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/44/Bananas_white_background_DS.jpg/220px-Bananas_white_background_DS.jpg" + }, + { + "line": "Do you like vegetables because I love you from my head **tomatoes**.", + "image": "https://vignette.wikia.nocookie.net/veggietales-the-ultimate-veggiepedia/images/e/ec/Bobprofile.jpg/revision/latest?cb=20161227190344" + }, + { + "line": "Do you like science because I've got my **ion** you.", + "image": "https://www.chromacademy.com/lms/sco101/assets/c1_010_equations.jpg" + }, + { + "line": "Are you an angle? Because you are **acute**.", + "image": "https://juicebubble.co.za/wp-content/uploads/2018/03/acute-angle-white-400x400.png" + }, + { + "line": "If you were a fruit, you'd be a **fineapple**." + }, + { + "line": "Did you swallow magnets? Cause you're **attractive**.", + "image": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/93/Magnetic_quadrupole_moment.svg/1200px-Magnetic_quadrupole_moment.svg.png" + }, + { + "line": "Hey pretty thang, do you have a name or can I call you mine?" + }, + { + "line": "Is your name Wi-Fi? Because I'm feeling a connection.", + "image": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/ae/WiFi_Logo.svg/1200px-WiFi_Logo.svg.png" + }, + { + "line": "Are you Australian? Because you meet all of my **koala**fications.", + "image": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/49/Koala_climbing_tree.jpg/240px-Koala_climbing_tree.jpg" + }, + { + "line": "If I were a cat I'd spend all 9 lives with you.", + "image": "https://upload.wikimedia.org/wikipedia/commons/4/4d/Cat_November_2010-1a.jpg" + }, + { + "line": "My love for you is like dividing by 0. It's undefinable.", + "image": "https://i.pinimg.com/originals/05/f5/9a/05f59a9fa44689e3435b5e46937544bb.png" + }, + { + "line": "Take away gravity, I'll still fall for you.", + "image": "https://i.pinimg.com/originals/05/f5/9a/05f59a9fa44689e3435b5e46937544bb.png" + }, + { + "line": "Are you a criminal? Because you just stole my heart.", + "image": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/98/Hinged_Handcuffs_Rear_Back_To_Back.JPG/174px-Hinged_Handcuffs_Rear_Back_To_Back.JPG" + }, + { + "line": "Hey babe I'm here. What were your other two wishes?", + "image": "https://upload.wikimedia.org/wikipedia/en/thumb/0/0c/The_Genie_Aladdin.png/220px-The_Genie_Aladdin.png" + } + ] +} diff --git a/bot/resources/holidays/valentines/valenstates.json b/bot/resources/holidays/valentines/valenstates.json new file mode 100644 index 00000000..c58a5b7c --- /dev/null +++ b/bot/resources/holidays/valentines/valenstates.json @@ -0,0 +1,122 @@ +{ + "Australia": { + "text": "Australia is the oldest, flattest and driest inhabited continent on earth. It is one of the 18 megadiverse countries, featuring a wide variety of plants and animals, the most iconic ones being the koalas and kangaroos, as well as its deadly wildlife and trees falling under the Eucalyptus genus.", + "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/88/Flag_of_Australia_%28converted%29.svg/1920px-Flag_of_Australia_%28converted%29.svg.png" + }, + "Austria": { + "text": "Austria is part of the european continent, lying in the alps. Due to its location, Austria possesses a variety of very tall mountains like the Großglockner (3798 m) or the Wildspitze (3772 m).", + "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/41/Flag_of_Austria.svg/1920px-Flag_of_Austria.svg.png" + }, + "Brazil": { + "text": "Being the largest and most populated country in South and Latin America, Brazil, as one of the 18 megadiverse countries, features a wide variety of plants and animals, especially in the Amazon rainforest, the most biodiverse rainforest in the world.", + "flag": "https://upload.wikimedia.org/wikipedia/en/thumb/0/05/Flag_of_Brazil.svg/1280px-Flag_of_Brazil.svg.png" + }, + "Canada": { + "text": "Canada is the second-largest country in the world measured by total area, only surpassed by Russia. It's widely known for its astonishing national parks.", + "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d9/Flag_of_Canada_%28Pantone%29.svg/1920px-Flag_of_Canada_%28Pantone%29.svg.png" + }, + "Croatia": { + "text": "Croatia is a country at the crossroads of Central and Southeast Europe, mostly known for its beautiful beaches and waters.", + "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1b/Flag_of_Croatia.svg/1920px-Flag_of_Croatia.svg.png" + }, + "England": { + "text": "", + "flag": "https://upload.wikimedia.org/wikipedia/en/thumb/b/be/Flag_of_England.svg/1920px-Flag_of_England.svg.png" + }, + "Finland": { + "text": "", + "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/bc/Flag_of_Finland.svg/1920px-Flag_of_Finland.svg.png" + }, + "France": { + "text": "", + "flag": "https://upload.wikimedia.org/wikipedia/en/thumb/c/c3/Flag_of_France.svg/1920px-Flag_of_France.svg.png" + }, + "Germany": { + "text": "", + "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Flag_of_Germany.svg/1920px-Flag_of_Germany.svg.png" + }, + "Greece": { + "text": "", + "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5c/Flag_of_Greece.svg/1920px-Flag_of_Greece.svg.png" + }, + "Iceland": { + "text": "", + "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/ce/Flag_of_Iceland.svg/1280px-Flag_of_Iceland.svg.png" + }, + "India": { + "text": "", + "flag": "https://upload.wikimedia.org/wikipedia/en/thumb/4/41/Flag_of_India.svg/1920px-Flag_of_India.svg.png" + }, + "Indonesia": { + "text": "", + "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9f/Flag_of_Indonesia.svg/1920px-Flag_of_Indonesia.svg.png" + }, + "Ireland": { + "text": "", + "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/45/Flag_of_Ireland.svg/1920px-Flag_of_Ireland.svg.png" + }, + "Italy": { + "text": "", + "flag": "https://upload.wikimedia.org/wikipedia/en/thumb/0/03/Flag_of_Italy.svg/1920px-Flag_of_Italy.svg.png" + }, + "Mexico": { + "text": "", + "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fc/Flag_of_Mexico.svg/1920px-Flag_of_Mexico.svg.png" + }, + "New Zealand": { + "text": "", + "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3e/Flag_of_New_Zealand.svg/1920px-Flag_of_New_Zealand.svg.png" + }, + "Norway": { + "text": "", + "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d9/Flag_of_Norway.svg/1280px-Flag_of_Norway.svg.png" + }, + "Peru": { + "text": "", + "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/cf/Flag_of_Peru.svg/1920px-Flag_of_Peru.svg.png" + }, + "Portugal": { + "text": "", + "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5c/Flag_of_Portugal.svg/1920px-Flag_of_Portugal.svg.png" + }, + "Scotland": { + "text": "", + "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/10/Flag_of_Scotland.svg/1920px-Flag_of_Scotland.svg.png" + }, + "Slovenia": { + "text": "", + "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f0/Flag_of_Slovenia.svg/1920px-Flag_of_Slovenia.svg.png" + }, + "South Africa": { + "text": "", + "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/af/Flag_of_South_Africa.svg/1920px-Flag_of_South_Africa.svg.png" + }, + "Spain": { + "text": "", + "flag": "https://upload.wikimedia.org/wikipedia/en/thumb/9/9a/Flag_of_Spain.svg/1920px-Flag_of_Spain.svg.png" + }, + "Sweden": { + "text": "", + "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4c/Flag_of_Sweden.svg/1920px-Flag_of_Sweden.svg.png" + }, + "Switzerland": { + "text": "", + "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/08/Flag_of_Switzerland_%28Pantone%29.svg/1024px-Flag_of_Switzerland_%28Pantone%29.svg.png" + }, + "Turkey": { + "text": "", + "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b4/Flag_of_Turkey.svg/1920px-Flag_of_Turkey.svg.png" + }, + "United States": { + "text": "", + "flag": "https://upload.wikimedia.org/wikipedia/en/thumb/a/a4/Flag_of_the_United_States.svg/1920px-Flag_of_the_United_States.svg.png" + }, + "Vietnam": { + "text": "", + "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/21/Flag_of_Vietnam.svg/1920px-Flag_of_Vietnam.svg.png" + }, + "Wales": { + "text": "", + "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a9/Flag_of_Wales_%281959%E2%80%93present%29.svg/1920px-Flag_of_Wales_%281959%E2%80%93present%29.svg.png" + } +} diff --git a/bot/resources/holidays/valentines/valentine_facts.json b/bot/resources/holidays/valentines/valentine_facts.json new file mode 100644 index 00000000..e6f826c3 --- /dev/null +++ b/bot/resources/holidays/valentines/valentine_facts.json @@ -0,0 +1,24 @@ +{ + "whois": "Saint Valentine, officially Saint Valentine of Rome, was a widely recognized 3rd-century christian saint, commemorated on February 14. He was a priest and bishop, ministering persecuted Christians in the Roman Empire, and is associated with a tradition of courtly love since the High Middle Ages, a period commenced around the year 1000AD and lasting until around 1250AD. He was martyred and buried at a Christian cemetery on the Via Flaminia on February 14.\n\nThere are a bunch of inconsistencies in the identification of the saint, however there are evidences for 3 saints that appear in connection with February 14. One of them, Saint Valentine of Terni, is believed to be the one associated with a vision restoration miracle, which happening during his imprisonment. In that, he restored the eyesight of his jailer's daughter, and, on the evening before his execution, supposedly sent her a letter signed with 'Your Valentine' (tuum valentinum). This makes this saint the one we today associate with Saint Valentine's Day.\n\nThe artist Cicero Moraes attempted a facial reconstruction of Saint Valentine, which can be seen in the thumbnail.", + "titles": [ + "\u2764 Facts \u00e1 la carte \u2764", + "\u2764 Would you like some cheese with your wi... facts? \u2764", + "\u2764 Facts to raise your pulse \u2764", + "\u2764 Love Facts, Episode #42 \u2764", + "\u2764 It's a fact not a fact, duh \u2764", + "\u2764 Candlelight din... facts! \u2764" + ], + "text": [ + "The expression 'From your Valentine' derives from a legend in which Saint Valentine, imprisoned after persecution and not wanting to convert to Roman paganism, performed a miracle on Julia, his jailer Asterius's blind daughter, restoring her eyesight. On the evening before his execution, he is supposed to have written a letter to the jailers daughter, signing as 'Your Valentine' (tuum valentinum).", + "Valentine's Day wasn't really associated with anything romantic, until the 14th century England where it's association with romantic love had begun from within the circle of Geoffrey Chaucer, a famous english poet and author, also called 'Father of English literature' for his work. He is best known for 'The Caunterbury Tales', a collection of 24 stories, which are presented as part of a story-telling contest by a group of pilgrims on their travel from London to Canterbury.", + "It's only been roughly 300 years, that the Valentine's Day evolved into what we know today.", + "The wide usage of hearts on Valentine's day derived from a legend, in which Saint Valentine cut hearts from parchment, giving them to persecuted Christians and soldiers married by him. He did that \"to remind these man of their vows and God's love\"", + "In 1797, a British publisher developed \"The Young Man's Valentine Writer\", to assist young men in composing their own sentimental verses to ladies they felt attracted to.", + "If you've never gotten any handwritten Valentine cards, this may be due to the fact, that in the 19th century, handwritten notes have given away to mass-produced greeting cards.", + "In 1868, a British chocolate company called Cadbury created so called 'Fancy Boxes', which essentially were a decorated box of chocolates in the shape of a heart. This set a trend, such that these boxes were quickly associated with Valentine's Day.", + "Roses are red,\nviolet's are blue,\nI can't rhyme,\nbut I still do.\n\u200b\nThis poem in particular,\nit will stay forever,\nderives from The Faerie Queene,\nan epic poem you probably have never seen.\n\n\"She bath'd with roses red, and violets blew,\nAnd all the sweetest flowres, that in the forrest grew.\"\n\nThese verses, with most immense sway,\nlead to the poem we still hear today.", + "The earliest Valentine poem known is a rondeau, a form of medieval/renaissance French poetry, composed by Charles, Duke of Orl\u00e9ans to his wife:\n\n\"Je suis desja d'amour tann\u00e9,\nMa tres doulce Valentin\u00e9e\"", + "There's a form of cryptological communication called 'Floriography', in which you communicate through flowers. Meaning has been attributed to flowers for thousands of years, and some form of floriography has been practiced in traditional cultures throughout Europe, Asia, and Africa. Here are some meanings for roses you might want to take a look at, if you plan on gifting your loved one a bouquet of roses on Valentine's Day:\n\u200b\nRed: eternal love\nPink: young, developing love\nWhite: innocence, fervor, loyalty\nOrange: happiness, security\nViolet: love at first sight\nBlue: unfulfilled longing, quiet desire\nYellow: friendship, jealousy, envy, infidelity\nBlack: unfulfilled longing, quiet desire, grief, hatred, misfortune, death", + "Traditionally, young girls in the U.S. and the U.K. believed they could tell what type of man they would marry depending on the type of bird they saw first on Valentine's Day. If they saw a blackbird, they would marry a clergyman, a robin redbreast indicated a sailor, and a goldfinch indicated a rich man. A sparrow meant they would marry a farmer, a bluebird indicated a happy man, and a crossbill meant an argumentative man. If they saw a dove, they would marry a good man, but seeing a woodpecker meant they would not marry at all." + ] +} diff --git a/bot/resources/holidays/valentines/zodiac_compatibility.json b/bot/resources/holidays/valentines/zodiac_compatibility.json new file mode 100644 index 00000000..ea9a7b37 --- /dev/null +++ b/bot/resources/holidays/valentines/zodiac_compatibility.json @@ -0,0 +1,262 @@ +{ + "Aries":[ + { + "Zodiac" : "Sagittarius", + "description" : "The Archer is one of the most compatible signs Aries should consider when searching out relationships that will bear fruit. Sagittarians share a certain love of freedom with Aries that will help the two of them conquer new territory together.", + "compatibility_score" : "87%" + }, + { + "Zodiac" : "Leo", + "description" : "Leo is the center of attention and typically the life of the party, but can also be courageous, bold, and powerful. These two signs share a gregarious nature that helps them walk life's path in sync. They may vie occasionally to see which one leads the way, but all things considered, they share a great capacity for compatibility.", + "compatibility_score" : "83%" + }, + { + "Zodiac" : "Aquarius", + "description" : "Aquarius' need for personal space dovetails nicely with Aries' love of freedom. This doesn't mean that the Ram and the Water Bearer can't forge a deep bond; in fact, quite the opposite. Their mutual respect for one another's needs and space leaves room for both to grow on their own and together.", + "compatibility_score" : "68%" + }, + { + "Zodiac" : "Gemini", + "description" : "The Twins are known for their adaptability, a trait that easily follows Aries' need to lead their closest companions down new paths. Geminis are also celebrated for their personal charm and intellect, traits the discerning Ram is also capable of fully appreciating.", + "compatibility_score" : "74%" + } + ], + "Taurus":[ + { + "Zodiac" : "Virgo", + "description" : "Although these signs have their set of differences, the Virgo Taurus compatibility is usually pretty strong. This is because both the signs want the same thing ultimately and have generally synchronous ways of reaching those points. This helps them complement each other and create a healthy relationship between them.", + "compatibility_score" : "73%" + }, + { + "Zodiac" : "Capricorn", + "description" : "A great compatibility is seen in this match as far as the philosophical and spiritual aspects of life are concerned. Taurus and Capricorn have a practical approach towards life. The ambitions and calmness of the Goat will attract the Bull, who will attract the former with his strong determination.The compatibility between these two zodiac signs is at the greatest height due to their mutual understanding, faith and consonance.", + "compatibility_score" : "89%" + }, + { + "Zodiac" : "Pisces", + "description" : "This relationship will survive the test of time if both parties involved have unbreakable trust in each other and nurture that connection they have painstakingly built over the years. They must remember to be honest and committed to their partner through all times.If natural communication flows between them like clockwork, this will be a beautiful love story with a prominent tag of 'happily-ever-after' pinned right to it!", + "compatibility_score" : "88%" + }, + { + "Zodiac" : "Cancer", + "description" : "This is a peaceful union of two reliable and kind souls. It is one of the strong zodiac pairings in terms of compatibility, and certainly has a real chance of lasting a lifetime in astrological charts. If Taurus man and Cancer woman and vice-versa are mature enough to handle the occasional friction, which they usually are, their bond will transcend all boundaries and is likely to grow in strength through the years.", + "compatibility_score" : "91%" + } + ], + "Gemini":[ + { + "Zodiac" : "Aries", + "description" : "The theorem of astrology says that Aries and Gemini have a zero tolerance for boredom and will at once get rid of anything dull. An Arian will let a Geminian enjoy his personal freedom and the Gemini will respect his individuality.", + "compatibility_score" : "74%" + }, + { + "Zodiac" : "Leo", + "description" : "Gemini and Leo love match that can go through extravagant heights and crushing lows, but one is sure to leave both of them breathless. This is a pair that forms one of the most exciting relationships of the zodiac chart, but the converse is equally true; they are vulnerable to experiencing terrible lows.", + "compatibility_score" : "82%" + }, + { + "Zodiac" : "Libra", + "description" : "It is relatively easier for them to adjust to each other as while they might have different outlooks to some things in life, they rarely collide or crash against one another. Hence, it is easy for them to go forward in harmony, forming a formidable bond in the process.", + "compatibility_score" : "78%" + }, + { + "Zodiac" : "Aquarius", + "description" : "This is one of the most successful pairs of the zodiac chart.If they manage to smooth this solitary wrinkle, this will be a beautiful union for both the parties involved. It will turn into a gift that will keep giving happiness, contentment and encouragement to the air signs.", + "compatibility_score" : "91%" + } + ], + "Cancer":[ + { + "Zodiac" : "Taurus", + "description" : "The Cancer Taurus zodiac relationship compatibility is strong because of their mutual love for safety, stability, and comfort. Their mutual understanding will always be powerful, which will be the pillar of strength of their relationship.", + "compatibility_score" : "91%" + }, + { + "Zodiac" : "Scorpio", + "description" : "The Cancer man and Scorpio woman and vice-versa connect on an intuitive level and will find refuge in each other from the outset of the relationship.", + "compatibility_score" : "79%" + }, + { + "Zodiac" : "Virgo", + "description" : "This is a match made in heaven! Very few zodiac combinations work as well as Cancer and Virgo. Their blissful union is one which is generally filled with memories of happiness, contentment, loyalty, and harmony. They will build an extremely strong bond with each other that can pass all the tests of time and emerge stronger after every challenge that is thrown in its way!", + "compatibility_score" : "77%" + } + ], + "Leo":[ + { + "Zodiac" : "Aries", + "description" : "A Leo is generous and an Arian is open to life. Sharing the same likes and dislikes, they both crave for fun, romance and excitement. A Leo respects an Arian's need for freedom because an Arian does not interfere much in the life of a Leo. Aries will love the charisma and ideas of the Leo.", + "compatibility_score" : "83%" + }, + { + "Zodiac" : "Gemini", + "description" : "This is one of the most compatible pairings in the zodiac chart and if the minor friction is handled with maturity by both the parties, the Leo and Gemini compatibility relationship can last a lifetime. A lifetime, which will be filled with love, happiness, and success.", + "compatibility_score" : "82%" + }, + { + "Zodiac" : "Libra", + "description" : "he thing that attributes for the greater compatibility of this love match is that both the signs like to complement each other. Leo and Libra love to attend social gatherings. They like to engage themselves in romance and act as lovers. This love match is strong enough to face adverse situations due to their opposite individual characters. This pair likes to enjoy candlelit dinners, long drives, dancing", + "compatibility_score" : "75%" + }, + { + "Zodiac" : "Sagittarius", + "description" : "The key for this relationship to work is that both Leo star sign and Sagittarius star sign natives should know where they want it to go in the long run. If they are ready for a long-time commitment and are mutually aware of the same, they will form a brilliant and buoyant bond together. It can last for a lifetime and will be characterized by the excitement, love, happiness, and unending loyalty that Leo and Sagittarius will bring to each other’s life.", + "compatibility_score" : "75%" + } + ], + "Virgo":[ + { + "Zodiac" : "Taurus", + "description" : "Although these signs have their set of differences, the Virgo Taurus compatibility is usually pretty strong. This is because both the signs want the same thing ultimately and have generally synchronous ways of reaching those points. This helps them complement each other and create a healthy relationship between them.", + "compatibility_score" : "73%" + }, + { + "Zodiac" : "Cancer", + "description" : "This is one of the few zodiac compatibilities that look great not only on paper, but also in reality. The shared outlook that Cancer and Virgo have towards life makes their relationship a positive, reliable, and beautiful entity for both the sides.", + "compatibility_score" : "77%" + }, + { + "Zodiac" : "Scorpio", + "description" : "The Virgo Scorpio love match definitely has something unique to offer to the zodiac world, and to both the parties involved in this pair. If they manage to control their negative traits, this relationship truly has the chance to survive everything life throws at it.", + "compatibility_score" : "76%" + }, + { + "Zodiac" : "Capricorn", + "description" : "The compatibility of this match is quite well as both rely on each other and possess the maturity to understand their individual outlook. Capricorn plans in advance and steadily reaches his goal and a Virgo has faith in his abilities and skills.", + "compatibility_score" : "77%" + } + ], + "Libra":[ + { + "Zodiac" : "Leo", + "description" : "Libra and Leo love match can work well for both the partners and truly help them learn from each other and grow individually, as well as together. Libra and Leo, when in the right frame of mind, form a formidable couple that attracts admiration and respect everywhere it goes.", + "compatibility_score" : "75%" + }, + { + "Zodiac" : "Aquarius", + "description" : "Libra Aquarius Relationship has a good chance of working because of the immense understanding that both the zodiac signs have for each other. They are likely to fight less as Libra believes in avoiding conflict and Aquarius usually is an extremely open-minded and non-judgmental sign.", + "compatibility_score" : "68%" + }, + { + "Zodiac" : "Gemini", + "description" : "It is relatively easier for them to adjust to each other as while they might have different outlooks to some things in life, they rarely collide or crash against one another. Hence, it is easy for them to go forward in harmony, forming a formidable bond in the process.", + "compatibility_score" : "78%" + }, + { + "Zodiac" : "sagittarius", + "description" : "If Sagittarius and Libra want to make it with each other, they must give themselves the time to understand each other fully. Rushing through the relationship will only need to problems in the future and that can be avoided if both the parties are careful not to get too ahead of themselves at any point.", + "compatibility_score" : "71%" + } + ], + "Scorpio":[ + { + "Zodiac" : "Cancer", + "description" : "This union is not unusual, but will take a fair share of work in the start. A strong foundation of clear cut communication is mandatory to make this a loving and stress free relationship!", + "compatibility_score" : "79%" + }, + { + "Zodiac" : "Virgo", + "description" : "Very rarely does it so happen that a pairing can be so perfect and perfectly horrid at the same time. As such, it is imperative that the individuals involved take time before they jump into a serious commitment. Knowing these two zodiacs, commitments last for a lifetime.", + "compatibility_score" : "76%" + }, + { + "Zodiac" : "Capricorn", + "description" : "The relationship of Scorpio and Capricorn can be inspiring for both partners to search for the truth, dig up under their family tree and deal with any unresolved karma and debt. They are both deep and don’t take things lightly, and this will help them build a strong foundation for a relationship that can last for a long time.", + "compatibility_score" : "72%" + }, + { + "Zodiac" : "Pisces", + "description" : "Scorpio-Pisces is one of the most compatible signs of the zodiac calendar. They have a gamut of intense emotions that are consistently in sync with each other. Only if the Scorpion learns to let it go once in a while and the Pisces develops a sense of self-assurance, it will be a successful partnership that will meet its short-term as well as long-term goals.", + "compatibility_score" : "81%" + } + ], + "Sagittarius":[ + { + "Zodiac" : "Aries", + "description" : "Sagittarius and Aries can make a very compatible pair. Their relationship will have a lot of passion, enthusiasm, and energy. These are very good traits to make their relationship deeper and stronger. Both Aries and Sagittarius will enjoy each other's company and their energy level rises as the relationship grows. Both will support and help in fighting hardships and failures.", + "compatibility_score" : "87%" + }, + { + "Zodiac" : "Leo", + "description" : "While there might be a few blips in the Sagittarius Leo relationship, they are certainly one of the most compatible couples when it comes to astrological studies. They can bring the best out of each other and shine in the light of their positive all exciting relationship.", + "compatibility_score" : "75%" + }, + { + "Zodiac" : "Libra", + "description" : "The connectivity between Sagittarians and Librans is amazing. Librans are inclined towards maintaining balance and harmony. Sagittarians are intelligent and fun loving. But, Librans have a strange trait of transiting from one phase of emotion to other.", + "compatibility_score" : "71%" + }, + { + "Zodiac" : "Aquarius", + "description" : "This is a combination where the positives outweigh the negatives by a considerable margin. As long as this pair builds and retains their passion for each other in the relationship, it will be a hit that can pass every test that is thrown at it.The Sagittarius Aquarius compatibility is one of a kind and can go the distance in most cases.", + "compatibility_score" : "83%" + } + ], + "Capricorn":[ + { + "Zodiac" : "Taurus", + "description" : "This is one of the most grounded and reliable bonds of the zodiac chart. If Capricorn and Taurus do find a way to handle their minor issues, they have a good chance of making it together and that too, in a happy, peaceful, and healthy relationship.", + "compatibility_score" : "89%" + }, + { + "Zodiac" : "Virgo", + "description" : "Virgo and Capricorn will connect to each other on a visceral level and will find enduring stability in their relationship. Since this bond is something they can rely on, they tend to ease up individually and thus, are likely to be more relaxed and happier in life.", + "compatibility_score" : "77%" + }, + { + "Zodiac" : "Scorpio", + "description" : "Though they have a couple of blips in the relationship, the combination of Scorpion and Capricorn is a match with powerful compatibility. They will shine with each other, develop each other, and build a beautiful bond with each other. If and when the two decide to commit to each other, their relationship is one that is likely to go the distance nine times out of ten!", + "compatibility_score" : "70%" + }, + { + "Zodiac" : "Pisces", + "description" : "Pisces and Capricorn signs have primary difference of qualities, but their union is aided seamlessly due to that very discrepancy of their personas. It is a highly compatible relationship that can go a long way with mutual respect, amicable understanding and immense trust between the concerned parties.", + "compatibility_score" : "76%" + } + ], + "Aquarius":[ + { + "Zodiac" : "Aries", + "description" : "The relationship of Aries and Aquarius is very exciting, adventurous and interesting. They will enjoy each other's company as both of them love fun and freedom.This is a couple that lacks tenderness. They are not two brutes who let their relationship fade as soon as their passion does.", + "compatibility_score" : "72%" + }, + { + "Zodiac" : "Gemini", + "description" : "If you want to see a relationship that is full of conversation, ideas and \"eureka moments\", you observe the Aquarius Gemini relationship. They will enjoy a camaraderie that you won't find in most zodiac relationships.", + "compatibility_score" : "85%" + }, + { + "Zodiac" : "Libra", + "description" : "The couple will share a lot of similar qualities and will enjoy the relationship. Both of them love to socialize with people and to make new friends.", + "compatibility_score" : "68%" + }, + { + "Zodiac" : "Sagittarius", + "description" : "These two will get together when it is time for both of them to go through a change in their lives or leave a partner they feel restricted with. Their relationship is often a shiny beacon to everyone around them because it gives priority to the future and brings hope of a better time.", + "compatibility_score" : "83%" + } + ], + "Pisces":[ + { + "Zodiac" : "Taurus", + "description" : "This relationship will survive the test of time if both parties involved have unbreakable trust in each other and nurture that connection they have painstakingly built over the years. They must remember to be honest and committed to their partner through all times.If natural communication flows between them like clockwork, this will be a beautiful love story with a prominent tag of ‘happily-ever-after’ pinned right to it!", + "compatibility_score" : "88%" + }, + { + "Zodiac" : "Cancer", + "description" : "The Pisces Cancer union is a beautiful one. Both zodiacs are normally blessed with appealing looks and attractive minds. They are both prone to be drawn to each other. Mostly, for very valid reasons. However, both the zodiac star signs need to make sure that they do not let their love overpower them.", + "compatibility_score" : "72%" + }, + { + "Zodiac" : "Scorpio", + "description" : "Though there are a few problems that might test this relationship, these star sign compatibility of two water signs has many features that can help it survive. Pisces and Scorpio are both passionate in their own ways and thus, end up striking the perfect rhythm with each other.If and when they decide to commit to the relationship, they can script a special story of camaraderie and bliss with each other.", + "compatibility_score" : "81%" + }, + { + "Zodiac" : "Capricorn", + "description" : "A relationship between Capricorn and Pisces tells a story about possibilities of inspiration. If someone like Capricorn can be pulled into a crazy love story, exciting and unpredictable, this must be done by Pisces. In return, Capricorn will offer their Pisces partner stability, peace and some rest from their usual emotional tornadoes. There is a fine way in which Capricorn can help Pisces be more realistic and practical, while feeling more cheerful and optimistic themselves.", + "compatibility_score" : "76%" + } + ] + +} diff --git a/bot/resources/holidays/valentines/zodiac_explanation.json b/bot/resources/holidays/valentines/zodiac_explanation.json new file mode 100644 index 00000000..33864ea5 --- /dev/null +++ b/bot/resources/holidays/valentines/zodiac_explanation.json @@ -0,0 +1,122 @@ +{ + "Aries": { + "start_at": "2020-03-21", + "end_at": "2020-04-19", + "About": "Amazing people born between **March 21** to **April 19**. Aries loves to be number one, so it\u2019s no surprise that these audacious rams are the first sign of the zodiac. Bold and ambitious, Aries dives headfirst into even the most challenging situations.", + "Motto": "***\u201cWhen you know yourself, you're empowered. When you accept yourself, you're invincible.\u201d***", + "Strengths": "Courageous, determined, confident, enthusiastic, optimistic, honest, passionate.", + "Weaknesses": "Impatient, moody, short-tempered, impulsive, aggressive.", + "full_form": "__**A**__ssertive, __**R**__efreshing, __**I**__ndependent, __**E**__nergetic, __**S**__exy", + "url": "https://www.horoscope.com/images-US/signs/profile-aries.png" + }, + "Taurus": { + "start_at": "2020-04-20", + "end_at": "2020-05-20", + "About": "Amazing people born between **April 20** to **May 20**. Taurus is an earth sign represented by the bull. Like their celestial spirit animal, Taureans enjoy relaxing in serene, bucolic environments surrounded by soft sounds, soothing aromas, and succulent flavors", + "Motto": "***\u201cNothing worth having comes easy.\u201d***", + "Strengths": "Reliable, patient, practical, devoted, responsible, stable.", + "Weaknesses": "Stubborn, possessive, uncompromising.", + "full_form": "__**T**__railblazing, __**A**__mbitious, __**U**__nwavering, __**R**__eliable, __**U**__nderstanding, __**S**__table", + "url": "https://www.horoscope.com/images-US/signs/profile-taurus.png" + }, + "Gemini": { + "start_at": "2020-05-21", + "end_at": "2020-06-20", + "About": "Amazing people born between **May 21** to **June 20**. Have you ever been so busy that you wished you could clone yourself just to get everything done? That\u2019s the Gemini experience in a nutshell. Appropriately symbolized by the celestial twins, this air sign was interested in so many pursuits that it had to double itself.", + "Motto": "***\u201cI manifest my reality.\u201d***", + "Strengths": "Gentle, affectionate, curious, adaptable, ability to learn quickly and exchange ideas.", + "Weaknesses": "Nervous, inconsistent, indecisive.", + "full_form": "__**G**__enerous, __**E**__motionally in tune, __**M**__otivated, __**I**__maginative, __**N**__ice, __**I**__ntelligent", + "url": "https://www.horoscope.com/images-US/signs/profile-gemini.png" + }, + "Cancer": { + "start_at": "2020-06-21", + "end_at": "2020-07-22", + "About": "Amazing people born between **June 21 ** to **July 22**. Cancer is a cardinal water sign. Represented by the crab, this crustacean seamlessly weaves between the sea and shore representing Cancer\u2019s ability to exist in both emotional and material realms. Cancers are highly intuitive and their psychic abilities manifest in tangible spaces: For instance, Cancers can effortlessly pick up the energies in a room.", + "Motto": "***\u201cI feel, therefore I am.\u201d***", + "Strengths": "Tenacious, highly imaginative, loyal, emotional, sympathetic, persuasive.", + "Weaknesses": "Moody, pessimistic, suspicious, manipulative, insecuremoody, pessimistic, suspicious, manipulative, insecure.", + "full_form": "__**C**__aring, __**A**__mbitious, __**N**__ourishing, __**C**__reative, __**E**__motionally intelligent, __**R**__esilient", + "url": "https://www.horoscope.com/images-US/signs/profile-cancer.png" + }, + "Leo": { + "start_at": "2020-07-23", + "end_at": "2020-08-22", + "About": "Amazing people born between **July 23** to **August 22**. Roll out the red carpet because Leo has arrived. Leo is represented by the lion and these spirited fire signs are the kings and queens of the celestial jungle. They\u2019re delighted to embrace their royal status: Vivacious, theatrical, and passionate, Leos love to bask in the spotlight and celebrate themselves.", + "Motto": "***\u201cIf you know the way, go the way and show the way\u2014you're a leader.\u201d***", + "Strengths": "Creative, passionate, generous, warm-hearted, cheerful, humorous.", + "Weaknesses": "Arrogant, stubborn, self-centered, lazy, inflexible.", + "full_form": "__**L**__eaders, __**E**__nergetic, __**O**__ptimistic", + "url": "https://www.horoscope.com/images-US/signs/profile-leo.png" + }, + "Virgo": { + "start_at": "2020-08-23", + "end_at": "2020-09-22", + "About": "Amazing people born between **August 23** to **September 22**. Virgo is an earth sign historically represented by the goddess of wheat and agriculture, an association that speaks to Virgo\u2019s deep-rooted presence in the material world. Virgos are logical, practical, and systematic in their approach to life. This earth sign is a perfectionist at heart and isn\u2019t afraid to improve skills through diligent and consistent practice.", + "Motto": "***\u201cMy best can always be better.\u201d***", + "Strengths": "Loyal, analytical, kind, hardworking, practical.", + "Weaknesses": "Shyness, worry, overly critical of self and others, all work and no play.", + "full_form": "__**V**__irtuous, __**I**__ntelligent, __**R**__esponsible, __**G**__enerous, __**O**__ptimistic", + "url": "https://www.horoscope.com/images-US/signs/profile-virgo.png" + }, + "Libra": { + "start_at": "2020-09-23", + "end_at": "2020-10-22", + "About": "Amazing people born between **September 23** to **October 22**. Libra is an air sign represented by the scales (interestingly, the only inanimate object of the zodiac), an association that reflects Libra's fixation on balance and harmony. Libra is obsessed with symmetry and strives to create equilibrium in all areas of life.", + "Motto": "***\u201cNo person is an island.\u201d***", + "Strengths": "Cooperative, diplomatic, gracious, fair-minded, social.", + "Weaknesses": "Indecisive, avoids confrontations, will carry a grudge, self-pity.", + "full_form": "__**L**__oyal, __**I**__nquisitive, __**B**__alanced, __**R**__esponsible, __**A**__ltruistic", + "url": "https://www.horoscope.com/images-US/signs/profile-libra.png" + }, + "Scorpio": { + "start_at": "2020-10-23", + "end_at": "2020-11-21", + "About": "Amazing people born between **October 23** to **November 21**. Scorpio is one of the most misunderstood signs of the zodiac. Because of its incredible passion and power, Scorpio is often mistaken for a fire sign. In fact, Scorpio is a water sign that derives its strength from the psychic, emotional realm.", + "Motto": "***\u201cYou never know what you are capable of until you try.\u201d***", + "Strengths": "Resourceful, brave, passionate, stubborn, a true friend.", + "Weaknesses": "Distrusting, jealous, secretive, violent.", + "full_form": "__**S**__eductive, __**C**__erebral, __**O**__riginal, __**R**__eactive, __**P**__assionate, __**I**__ntuitive, __**O**__utstanding", + "url": "https://www.horoscope.com/images-US/signs/profile-scorpio.png" + }, + "Sagittarius": { + "start_at": "2020-11-22", + "end_at": "2020-12-21", + "About": "Amazing people born between **November 22** to **December 21**. Represented by the archer, Sagittarians are always on a quest for knowledge. The last fire sign of the zodiac, Sagittarius launches its many pursuits like blazing arrows, chasing after geographical, intellectual, and spiritual adventures.", + "Motto": "***\u201cTowering genius disdains a beaten path.\u201d***", + "Strengths": "Generous, idealistic, great sense of humor.", + "Weaknesses": "Promises more than can deliver, very impatient, will say anything no matter how undiplomatic.", + "full_form": "__**S**__eductive, __**A**__dventurous, __**G**__rateful, __**I**__ntelligent, __**T**__railblazing, __**T**__enacious adept, __**A**__dept, __**R**__esponsible, __**I**__dealistic, __**U**__nparalled, __**S**__ophisticated", + "url": "https://www.horoscope.com/images-US/signs/profile-sagittarius.png" + }, + "Capricorn": { + "start_at": "2020-12-22", + "end_at": "2021-01-19", + "About": "Amazing people born between **December 22** to **January 19**. The last earth sign of the zodiac, Capricorn is represented by the sea goat, a mythological creature with the body of a goat and tail of a fish. Accordingly, Capricorns are skilled at navigating both the material and emotional realms.", + "Motto": "***\u201cI can succeed at anything I put my mind to.\u201d***", + "Strengths": "Responsible, disciplined, self-control, good managers.", + "Weaknesses": "Know-it-all, unforgiving, condescending, expecting the worst.", + "full_form": "__**C**__onfident, __**A**__nalytical, __**P**__ractical, __**R**__esponsible, __**I**__ntelligent, __**C**__aring, __**O**__rganized, __**R**__ealistic, __**N**__eat", + "url": "https://www.horoscope.com/images-US/signs/profile-capricorn.png" + }, + "Aquarius": { + "start_at": "2020-01-20", + "end_at": "2020-02-18", + "About": "Amazing people born between **January 20** to **February 18**. Despite the \u201caqua\u201d in its name, Aquarius is actually the last air sign of the zodiac. Aquarius is represented by the water bearer, the mystical healer who bestows water, or life, upon the land. Accordingly, Aquarius is the most humanitarian astrological sign.", + "Motto": "***\u201cThere is no me, there is only we.\u201d***", + "Strengths": "Progressive, original, independent, humanitarian.", + "Weaknesses": "Runs from emotional expression, temperamental, uncompromising, aloof.", + "full_form": "__**A**__nalytical, __**Q**__uirky, __**U**__ncompromising, __**A**__ction-focused, __**R**__espectful, __**I**__ntelligent, __**U**__nique, __**S**__incere", + "url": "https://www.horoscope.com/images-US/signs/profile-aquarius.png" + }, + "Pisces": { + "start_at": "2020-02-19", + "end_at": "2020-03-20", + "About": "Amazing people born between **February 19** to **March 20**. Pisces, a water sign, is the last constellation of the zodiac. It's symbolized by two fish swimming in opposite directions, representing the constant division of Pisces' attention between fantasy and reality. As the final sign, Pisces has absorbed every lesson \u2014 the joys and the pain, the hopes and the fears \u2014 learned by all of the other signs.", + "Motto": "***\u201cI have a lot of love to give, it only takes a little patience and those worth giving it all to.\u201d***", + "Strengths": "Compassionate, artistic, intuitive, gentle, wise, musical.", + "Weaknesses": "Fearful, overly trusting, sad, desire to escape reality, can be a victim or a martyr.", + "full_form": "__**P**__sychic, __**I**__ntelligent, __**S**__urprising, __**C**__reative, __**E**__motionally-driven, __**S**__ensitive", + "url": "https://www.horoscope.com/images-US/signs/profile-pisces.png" + } +} diff --git a/bot/resources/valentines/bemyvalentine_valentines.json b/bot/resources/valentines/bemyvalentine_valentines.json deleted file mode 100644 index 7d5d3705..00000000 --- a/bot/resources/valentines/bemyvalentine_valentines.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "valentine_poems": [ - - "If you were my rose,\nthen I'd be your sun,\npainting you rainbows when the rains come.\nI'd change my orbit to banish the night,\nas to keep you in my nurturing light.", - "If you were my world, then I'd be your moon,\nyour silent protector, a night-light in the gloom.\nOur fates intertwined, two bodies in motion through time and space,\nour dance of devotion.", - "If you were my island, then I'd be your sea,\ncaressing your shores, soft and gentle I'd be.\nMy tidal embrace would leave gifts on your sands,\nbut by current and storm, I'd ward your gentle lands.", - "If you were love's promise, then I would be time,\nyour constant companion till stars align.\nAnd though we are mere mortals,\ntrue love is divine,and my devotion eternal,\nto my one valentine.", - "Have I told you,\nValentine, That I’m all wrapped up in you?\nMy feelings for you bring to me A joy I never knew.\n \n You light up everything for me; In my heart you shine;\nIlluminating my whole life,\nMy darling Valentine.", - "My days are filled with yearning;\nMy nights are full of dreams.\nI’m always thinking of you;\nI’m in a trance, it seems.\n\n You’re all I ever wanted;\nI wish you could be mine;\nAnd so I have to ask you: Will you be my Valentine?", - "My Valentine, I love just you;\nMy devotion I declare.\nI’ll spend my life looking for ways To show you that I care.\n\nPlease say you feel the same for me;\nSay you’ll be forever mine;\nWe’ll share a life of happiness,\nMy treasured Valentine.", - "Every day with you is Valentine's Day, my love.\nEvery day is filled with romance, with love, with sharing and caring.\nEvery day I am reminded how blessed I am to have you as my,\nValentine, my sweetheart, my lover, my friend, my playmate, my companion.\nNo Valentine card, no words at all could express how much I love You,\nhow ecstatic I feel to know that you are mine.\nMy Valentine, every day,\nI'll try to show you that every day I love you more.", - "I lucked out when I met you, sweet thing.\nYou've brought richness to each day I exist.\nYou fill my days with the excitement of love,\nAnd you have since the moment we kissed.\nSo I celebrate Valentine's Day with you,\nWith a love that will always stay fresh and new.", - "You are my everything, Valentine.\nAs a desert creature longs for water, my thirst for you can never be slaked.\nIn a ho-hum day dragging on, thoughts of you bring excitement, joy and pleasure.\nAs a child opens the birthday gift he most wanted,\nI see everything I want in you.\nYou are my everything, Valentine.", - "My love for you is like the raging sea,\nSo powerful and deep it will forever be.\nThrough storm, wind, and heavy rain, It will withstand every pain.\nOur hearts are so pure and love so sweet.\nI love you more with every heartbeat!", - "A million stars up in the sky.\nOne shines brighter - I can't deny.\nA love so precious, a love so true,\na love that comes from me to you.\nThe angels sing when you are near.\nWithin your arms I have nothing to fear.\nYou always know just what to say.\nJust talking to you makes my day.\nI love you, honey, with all of my heart.\nTogether forever and never to part.", - "What do I do,\nWhen I'm still in love with you?\nYou walked away, Cause you didn't want to stay.\nYou broke my heart, you tore me apart.\nEvery day I wait for you, Telling myself our love was true.\nBut when you don't show, more tears start to flow.\nThat's when I know I have to let go.", - "When I say I love you, please believe it's true.\nWhen I say forever, know I'll never leave you.\nWhen I say goodbye, promise me you won't cry,\nBecause the day I'll be saying that will be the day I die.", - "Beauty isn't seen by eyes.\nIt's felt by hearts,\nRecognized by souls,\nIn the presence of love.", - "L is for \"laughter\" we had along the way.\nO is for \"optimism\" you gave me every day.\nV is for \"value\" of being my best friend.\nE is for \"eternity,\" a love that has no end.", - "If roses were red and violets could be blue,\nI'd take us away to a place just for two.\nYou'd see my true colors and all that I felt.\nI'd see that you could love me and nobody else.\nWe'd build ourselves a fortress where we'd run and play.\nYou'd be mine and I'd be yours till our dying day.\nThen I wake and realize you were never here.\nIt's all just my thoughts, my dreams, my hopes...\nBut now it's only tears!" - - ], - "valentine_compliments": [ - - "To the love of my life. I’m the luckiest person on Earth because I have you! I adore you! You’ve taught me that the best thing to hold onto in life is each other. You are my sweetheart, you are my life, you are my everything.", - "It was a million tiny little things that, when you added them all up, they meant we were supposed to be together.", - "When you smile, the whole world stops and stares for a while, cause you're amazing, just the way you are.", - "Take love, multiply it by infinity and take it to the depths of forever... and you still have only a glimpse of how I feel for you.", - "When you kiss me, the world just melts away. When you hold me in your arms, I feel safe. Being in love with you has made me see how wonderful life can be. I love you.", - "No matter how dark the days get, you still know how to make me smile. Even after all this time, you still take my breath away.", - "I don't know what my future holds, but I know I want you in it. I would travel to the moon and back just to see you smile.", - "I may not always say it, but know that with every breath in my body and every beat of my heart I know I will always love you.", - "Darling I will be loving you till we're 70. And baby my heart could still fall as hard at 23. And I'm thinking about how people fall in love in mysterious ways. Maybe just the touch of a hand. Oh me, I fall in love with you every single day. And I just wanna tell you I am. So honey now. Take me into your loving arms. Kiss me under the light of a thousand stars. Place your head on my beating heart. I'm thinking out loud. Maybe we found love right where we are.", - "I love you. I knew it the minute I met you. I'm sorry it took so long for me to catch up. I just got stuck.", - "You are truly beautiful from within. I am serious! It's not just about how pretty you are (which you are, of course), but you have a beautiful heart.", - "If you could see yourself through my eyes, you would know how much I love you. You hold a very special place in my heart! I will love you forever!", - "I don’t need a thousand reasons to feel special. All I need is you to live in this world. You are the sunshine of my life.", - "I wish to be everything that brings a smile on your face and happiness in your heart. I want to love you like no else ever did!", - "Every morning of my life gives you a new reason to love you and to appreciate you for what you’ve given me. You are the one that holds the key to my heart!", - "Each time I look at you, I just smile to myself and think, ‘I certainly could not have done better’. You are perfect the way you are. I love you honey.", - "Look at the computer keyboard, U and I were placed side by side. That’s how the alphabets should be arranged because my love will never cease to exist as long as it’s you and me." - - ] - -} diff --git a/bot/resources/valentines/date_ideas.json b/bot/resources/valentines/date_ideas.json deleted file mode 100644 index 995f14bb..00000000 --- a/bot/resources/valentines/date_ideas.json +++ /dev/null @@ -1,125 +0,0 @@ -{ - "ideas": [ - { - "name": "Chick flick marathon", - "description": "This is a very basic yet highly romantic way of spending the day with your partner. Take a few days to prepare the right playlist and create a romantic atmosphere at home. You can order out some food, open a bottle of wine and cuddle up in front of the TV." - }, - - { - "name": "Recreate your first date", - "description": "Rated pretty high on the “romantic gestures scale,” this is guaranteed to impress your significant other. It requires a good memory and a bit of work to make it just right, but it is well worth it. Walk down the same streets where you first kissed and have a couple of drinks in that old coffee shop where you had your first drinks together. Don’t be afraid to spend a bit extra and add a little romantic gift into the mix." - }, - { - "name": "Cook for your loved one", - "description": "Start researching good recipes for a romantic dinner for two, get the right ingredients and prepare a couple of practice dinners to make sure you’ve got your technique and presentation down pat. Cooking for someone can be a big turn on and you can create some incredible meals without spending too much money. Take it up a notch by dressing classy, decorating your dining room and presenting your partner with a printed menu." - }, - { - "name": "Organize your very own ancient Greek party", - "description": "Here’s another one of those creative date ideas for the stay-at-home couple. The ancient Greek private party can be a very fun and erotic experience. You can decorate by using big bowls full of grapes, spreading some white sheets all over the place, placing some plastic vines here and there, putting up a few posters depicting Greek parties and having plenty of wine lying around. Wear nothing but light sheets or costumes and channel some of that hot-blooded Greek spirit." - }, - { - "name": "A romantic weekend getaway in the mountains", - "description": "For those looking for a change of scenery and an escape from the busy city, there is nothing better than a quiet, romantic weekend in the mountains. There are plenty of fun activities like skiing that will keep you active. You can have fun making a snowman or engaging in a snowball fight, and, of course, there is plenty of privacy and great room service waiting for you back at your room." - }, - { - "name": "Fun day at the shooting range", - "description": "A bit unconventional but an incredibly fun and exciting date that will get your blood pumping and put a huge smile on your faces. Try out a number of guns and have a bit of a competition. Some outdoor ranges have fully automatic rifles, which are a blast to shoot." - }, - { - "name": "Rent an expensive sports car for a day", - "description": "Don’t be afraid to live large from time to time—even if you can’t afford the glamorous lifestyle of the stars, you can most definitely play pretend for a day. Put on some classy clothes and drive around town in a rented sports car. The quick acceleration and high speed are sure to provide an exhilarating experience. " - }, - { - "name": "Go on a shopping spree together", - "description": "Very few things can elicit such a huge dopamine rush as a good old shopping spree. Get some new sexy lingerie, pretty shoes, a nice shirt and tie, a couple of new video games or whatever else you need or want. This is a unique chance to bond, have fun and get some stuff that you’ve been waiting to buy for a while now." - }, - { - "name": "Hit the clubs", - "description": "For all the party animals out there, one of the best date ideas is to go out drinking, dancing, and just generally enjoying the night life. Visit a few good clubs, then go to an after-party and keep that party spirit going for as long as you can." - }, - { - "name": "Spend the day driving around the city and visiting new places", - "description": "This one is geared towards couples who have been together for a year or two and want to experience a few new things together. Visit a few cool coffee places on the other side of town, check out interesting restaurants you’ve never been to, and consider going to see a play or having fun at a comedy club on open mic night." - }, - { - "name": "Wine and chocolates at sunset", - "description": "Pick out a romantic location, such as a camping spot on a hill overlooking the city or a balcony in a restaurant with a nice view, open a bottle of wine and a box of chocolates and wait for that perfect moment when the sky turns fiery red to embrace and share a passionate kiss." - }, - { - "name": "Ice skating", - "description": "There is something incredibly fun about ice skating that brings people closer together and just keeps you laughing (maybe it’s all the falling and clinging to the other person for dear life). You can have some great fun and then move on to a more private location for some alone time." - }, - { - "name": "Model clothes for each other", - "description": "This one goes well when combined with a shopping spree, but you can just get a bunch of your clothes—old and new—from the closet, set up a catwalk area and then try on different combinations. You can be stylish, funny, handsome and sexy. It’s a great after-dinner show and a good way to transition into a more intimate atmosphere." - }, - { - "name": "Dance the night away", - "description": "If you and your significant other are relatively good dancers, or if you simply enjoy moving your body to the rhythm of the music, then a night at salsa club or similar venue is the perfect thing for you. Alternatively, you can set up dance floor at home, play your favorite music, have a few drinks and dance like there is no tomorrow." - }, - { - "name": "Organize a nature walk", - "description": "Being outside has many health benefits, but what you are going for is the beautiful view, seclusion, and the thrill of engaging in some erotic behavior out in the open. You can rent a cottage far from the city, bring some food and drinks, and explore the wilderness. This is nice way to spice things up a bit and get away from the loud and busy city life." - }, - { - "name": "Travel abroad", - "description": "This takes a bit of planning in advance and may be a bit costly, but if you can afford it, there are very few things that can match a trip to France, Italy, Egypt, Turkey, Greece, or a number of other excellent locations." - }, - { - "name": "Go on a hot-air balloon ride", - "description": "These are very fun and romantic—you get an incredible view, get to experience the thrill of flying, and you’ve got enough room for a romantic dinner and some champagne. Just be sure to wear warm clothes, it can get pretty cold high up in the air." - }, - { - "name": "A relaxing day at the spa", - "description": "Treat your body, mind and senses to a relaxing day at the spa. You and your partner will feel fresh, comfortable, relaxed, and sexy as hell—a perfect date for the more serious couples who don’t get to spend as much time with each other as they’d like." - }, - { - "name": "Fun times at a karaoke bar", - "description": "A great choice for couples celebrating their first Valentine’s Day together—it’s fairly informal and inexpensive, yet incredibly fun and allows for deeper bonding. Once you have a few drinks in your system and come to terms with the fact that you are making a complete fool of yourself, you’ll have the time of your life!" - }, - { - "name": "Horseback riding", - "description": "Horseback riding is incredibly fun, especially if you’ve never done it before. And what girl doesn’t dream of a prince coming to take her on an adventure on his noble steed? It evokes a sense of nobility and is a very good bonding experience." - }, - { - "name": "Plan a fun date night with other couples", - "description": "Take a break and rent a cabin in the woods, go to a mountain resort, a couple’s retreat, or just organize a huge date night at someone’s place and hang out with other couples. This is a great option for couples who have spent at least one Valentine’s Day together and allows you to customize your experience to suit your needs. Also, you can always retire early and get some alone time with your partner if you so desire." - }, - { - "name": "Go to a concert", - "description": "There are a whole bunch of things happening around Valentine’s Day, so go online and check out what’s happening near you. You’ll surely be able to find tickets for a cool concert or some type of festival with live music." - }, - { - "name": "Fancy night on the town", - "description": "Buy some elegant new clothes, rent a limo for the night and go to a nice restaurant, followed by a jazz club or gallery exhibition. Walk tall, make a few sarcastic quips, and have a few laughs with your partner while letting your inner snob take charge for a few hours." - }, - { - "name": "Live out a James Bond film at a casino", - "description": "A beautiful lady in a simple yet sensual, form-fitting, black dress, and a strong and handsome, if somewhat stern-looking man in a fine suit walk up to a roulette table with drinks in hand and place bets at random as they smile at each other seductively. This is a scenario most of us wish to play out, but rarely get a chance. It can be a bit costly, but this is one of the most incredibly adventurous and romantic date ideas." - }, - { - "name": "Go bungee jumping", - "description": "People in long-term relationships often talk about things like keeping a relationship fun and exciting, doing new things together, trusting each other and using aphrodisiacs. Well, bungee jumping is a fun, exhilarating activity you can both enjoy; it requires trust and the adrenaline rush you get from it is better than any aphrodisiac out there. Just saying, give it a shot and you won’t regret it. " - }, - { - "name": "Play some sports", - "description": "Some one-on-one basketball, a soccer match against another couple, a bit of tennis, or even something as simple as a table tennis tournament (make it fun by stripping off items of clothing when you lose a game). You can combine this with date idea #13 and paint team uniforms on each other and play in the nude." - }, - { - "name": "Take skydiving lessons", - "description": "An adrenaline-filled date, skydiving is sure to get your heart racing like crazy and leave you with a goofy grin for the rest of the day. You can offset all the excitement by ending the day with a quiet dinner at home." - }, - { - "name": "Go for some paintball", - "description": "Playing war games is an excellent way to get your body moving, focus on some of that hand-eye-coordination, and engage your brain in coming up with tactical solutions in the heat of the moment. It is also a great bonding experience, adrenaline-fueled fun, and role-playing all wrapped into one. And when you get back home, you can always act out the wounded soldier scenario." - }, - { - "name": "Couples’ Yoga", - "description": "Getting up close, hot, and sweaty? Sounds like a Valentine’s Day movie to me. By signing up with your partner for a couples’ yoga class, you can sneak in a workout while getting some face-to-face time with your date.This type of yoga focuses on poses that can be done with a partner, such as back-to-back bends, assisted stretches, and face-to-face breathing exercises. By working out together, you strengthen your bond while stretching away the stress of the week. Finish the date off by heading to the juice bar for a smoothie, or indulging in healthy salads for two. Expect to spend around $35 per person, or approximately $50 to $60 per couple." - }, - { - "name": "Volunteer Together", - "description": "Getting your hands dirty for a good cause might not be the first thing that pops into your mind when you think “romance,” but there’s something to be said for a date that gives you a glimpse of your partner’s charitable side. Consider volunteering at an animal rescue, where you might be able to play with pups or help a few lovebirds pick out their perfect pet. Or, sign up to visit the elderly at a care center, where you can be a completely different kind of Valentine for someone in need." - } - ] -} diff --git a/bot/resources/valentines/love_matches.json b/bot/resources/valentines/love_matches.json deleted file mode 100644 index 7df2dbda..00000000 --- a/bot/resources/valentines/love_matches.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "0": { - "titles": [ - "\ud83d\udc94 There's no real connection between you two \ud83d\udc94" - ], - "text": "The chance of this relationship working out is really low. You can get it to work, but with high costs and no guarantee of working out. Do not sit back, spend as much time together as possible, talk a lot with each other to increase the chances of this relationship's survival." - }, - "5": { - "titles": [ - "\ud83d\udc99 A small acquaintance \ud83d\udc99" - ], - "text": "There might be a chance of this relationship working out somewhat well, but it is not very high. With a lot of time and effort you'll get it to work eventually, however don't count on it. It might fall apart quicker than you'd expect." - }, - "20": { - "titles": [ - "\ud83d\udc9c You two seem like casual friends \ud83d\udc9c" - ], - "text": "The chance of this relationship working is not very high. You both need to put time and effort into this relationship, if you want it to work out well for both of you. Talk with each other about everything and don't lock yourself up. Spend time together. This will improve the chances of this relationship's survival by a lot." - }, - "30": { - "titles": [ - "\ud83d\udc97 You seem like you are good friends \ud83d\udc97" - ], - "text": "The chance of this relationship working is not very high, but its not that low either. If you both want this relationship to work, and put time and effort into it, meaning spending time together, talking to each other etc., than nothing shall stand in your way." - }, - "45": { - "titles": [ - "\ud83d\udc98 You two are really close aren't you? \ud83d\udc98" - ], - "text": "Your relationship has a reasonable amount of working out. But do not overestimate yourself there. Your relationship will suffer good and bad times. Make sure to not let the bad times destroy your relationship, so do not hesitate to talk to each other, figure problems out together etc." - }, - "60": { - "titles": [ - "\u2764 So when will you two go on a date? \u2764" - ], - "text": "Your relationship will most likely work out. It won't be perfect and you two need to spend a lot of time together, but if you keep on having contact, the good times in your relationship will outweigh the bad ones." - }, - "80": { - "titles": [ - "\ud83d\udc95 Aww look you two fit so well together \ud83d\udc95" - ], - "text": "Your relationship will most likely work out well. Don't hesitate on making contact with each other though, as your relationship might suffer from a lack of time spent together. Talking with each other and spending time together is key." - }, - "95": { - "titles": [ - "\ud83d\udc96 Love is in the air \ud83d\udc96", - "\ud83d\udc96 Planned your future yet? \ud83d\udc96" - ], - "text": "Your relationship will most likely work out perfect. This doesn't mean thought that you don't need to put effort into it. Talk to each other, spend time together, and you two won't have a hard time." - }, - "100": { - "titles": [ - "\ud83d\udc9b When will you two marry? \ud83d\udc9b", - "\ud83d\udc9b Now kiss already \ud83d\udc9b" - ], - "text": "You two will most likely have the perfect relationship. But don't think that this means you don't have to do anything for it to work. Talking to each other and spending time together is key, even in a seemingly perfect relationship." - } -} diff --git a/bot/resources/valentines/pickup_lines.json b/bot/resources/valentines/pickup_lines.json deleted file mode 100644 index eb01290f..00000000 --- a/bot/resources/valentines/pickup_lines.json +++ /dev/null @@ -1,97 +0,0 @@ -{ - "placeholder": "https://i.imgur.com/BB52sxj.jpg", - "lines": [ - { - "line": "Hey baby are you allergic to dairy cause I **laktose** clothes you're wearing", - "image": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8f/Cheese_%281105942243%29.jpg/800px-Cheese_%281105942243%29.jpg" - }, - { - "line": "I’m not a photographer, but I can **picture** me and you together.", - "image": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/57/2016_Minolta_Dynax_404si.JPG/220px-2016_Minolta_Dynax_404si.JPG" - }, - { - "line": "I seem to have lost my phone number. Can I have yours?" - }, - { - "line": "Are you French? Because **Eiffel** for you.", - "image": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/85/Tour_Eiffel_Wikimedia_Commons_%28cropped%29.jpg/240px-Tour_Eiffel_Wikimedia_Commons_%28cropped%29.jpg" - }, - { - "line": "Hey babe are you a cat? Because I'm **feline** a connection between us.", - "image": "https://upload.wikimedia.org/wikipedia/commons/4/4d/Cat_November_2010-1a.jpg" - }, - { - "line": "Baby, life without you is like a broken pencil... **pointless**.", - "image": "https://upload.wikimedia.org/wikipedia/commons/0/08/Pencils_hb.jpg" - }, - { - "line": "Babe did it hurt when you fell from heaven?" - }, - { - "line": "If I could rearrange the alphabet, I would put **U** and **I** together.", - "image": "https://images-na.ssl-images-amazon.com/images/I/51wJaFX%2BnGL._SX425_.jpg" - }, - { - "line": "Is your name Google? Because you're everything I'm searching for.", - "image": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/53/Google_%22G%22_Logo.svg/1024px-Google_%22G%22_Logo.svg.png" - }, - { - "line": "Are you from Starbucks? Because I like you a **latte**.", - "image": "https://upload.wikimedia.org/wikipedia/en/thumb/d/d3/Starbucks_Corporation_Logo_2011.svg/1200px-Starbucks_Corporation_Logo_2011.svg.png" - }, - { - "line": "Are you a banana? Because I find you **a peeling**.", - "image": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/44/Bananas_white_background_DS.jpg/220px-Bananas_white_background_DS.jpg" - }, - { - "line": "Do you like vegetables because I love you from my head **tomatoes**.", - "image": "https://vignette.wikia.nocookie.net/veggietales-the-ultimate-veggiepedia/images/e/ec/Bobprofile.jpg/revision/latest?cb=20161227190344" - }, - { - "line": "Do you like science because I've got my **ion** you.", - "image": "https://www.chromacademy.com/lms/sco101/assets/c1_010_equations.jpg" - }, - { - "line": "Are you an angle? Because you are **acute**.", - "image": "https://juicebubble.co.za/wp-content/uploads/2018/03/acute-angle-white-400x400.png" - }, - { - "line": "If you were a fruit, you'd be a **fineapple**." - }, - { - "line": "Did you swallow magnets? Cause you're **attractive**.", - "image": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/93/Magnetic_quadrupole_moment.svg/1200px-Magnetic_quadrupole_moment.svg.png" - }, - { - "line": "Hey pretty thang, do you have a name or can I call you mine?" - }, - { - "line": "Is your name Wi-Fi? Because I'm feeling a connection.", - "image": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/ae/WiFi_Logo.svg/1200px-WiFi_Logo.svg.png" - }, - { - "line": "Are you Australian? Because you meet all of my **koala**fications.", - "image": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/49/Koala_climbing_tree.jpg/240px-Koala_climbing_tree.jpg" - }, - { - "line": "If I were a cat I'd spend all 9 lives with you.", - "image": "https://upload.wikimedia.org/wikipedia/commons/4/4d/Cat_November_2010-1a.jpg" - }, - { - "line": "My love for you is like dividing by 0. It's undefinable.", - "image": "https://i.pinimg.com/originals/05/f5/9a/05f59a9fa44689e3435b5e46937544bb.png" - }, - { - "line": "Take away gravity, I'll still fall for you.", - "image": "https://i.pinimg.com/originals/05/f5/9a/05f59a9fa44689e3435b5e46937544bb.png" - }, - { - "line": "Are you a criminal? Because you just stole my heart.", - "image": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/98/Hinged_Handcuffs_Rear_Back_To_Back.JPG/174px-Hinged_Handcuffs_Rear_Back_To_Back.JPG" - }, - { - "line": "Hey babe I'm here. What were your other two wishes?", - "image": "https://upload.wikimedia.org/wikipedia/en/thumb/0/0c/The_Genie_Aladdin.png/220px-The_Genie_Aladdin.png" - } - ] -} diff --git a/bot/resources/valentines/valenstates.json b/bot/resources/valentines/valenstates.json deleted file mode 100644 index c58a5b7c..00000000 --- a/bot/resources/valentines/valenstates.json +++ /dev/null @@ -1,122 +0,0 @@ -{ - "Australia": { - "text": "Australia is the oldest, flattest and driest inhabited continent on earth. It is one of the 18 megadiverse countries, featuring a wide variety of plants and animals, the most iconic ones being the koalas and kangaroos, as well as its deadly wildlife and trees falling under the Eucalyptus genus.", - "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/88/Flag_of_Australia_%28converted%29.svg/1920px-Flag_of_Australia_%28converted%29.svg.png" - }, - "Austria": { - "text": "Austria is part of the european continent, lying in the alps. Due to its location, Austria possesses a variety of very tall mountains like the Großglockner (3798 m) or the Wildspitze (3772 m).", - "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/41/Flag_of_Austria.svg/1920px-Flag_of_Austria.svg.png" - }, - "Brazil": { - "text": "Being the largest and most populated country in South and Latin America, Brazil, as one of the 18 megadiverse countries, features a wide variety of plants and animals, especially in the Amazon rainforest, the most biodiverse rainforest in the world.", - "flag": "https://upload.wikimedia.org/wikipedia/en/thumb/0/05/Flag_of_Brazil.svg/1280px-Flag_of_Brazil.svg.png" - }, - "Canada": { - "text": "Canada is the second-largest country in the world measured by total area, only surpassed by Russia. It's widely known for its astonishing national parks.", - "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d9/Flag_of_Canada_%28Pantone%29.svg/1920px-Flag_of_Canada_%28Pantone%29.svg.png" - }, - "Croatia": { - "text": "Croatia is a country at the crossroads of Central and Southeast Europe, mostly known for its beautiful beaches and waters.", - "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1b/Flag_of_Croatia.svg/1920px-Flag_of_Croatia.svg.png" - }, - "England": { - "text": "", - "flag": "https://upload.wikimedia.org/wikipedia/en/thumb/b/be/Flag_of_England.svg/1920px-Flag_of_England.svg.png" - }, - "Finland": { - "text": "", - "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/bc/Flag_of_Finland.svg/1920px-Flag_of_Finland.svg.png" - }, - "France": { - "text": "", - "flag": "https://upload.wikimedia.org/wikipedia/en/thumb/c/c3/Flag_of_France.svg/1920px-Flag_of_France.svg.png" - }, - "Germany": { - "text": "", - "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Flag_of_Germany.svg/1920px-Flag_of_Germany.svg.png" - }, - "Greece": { - "text": "", - "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5c/Flag_of_Greece.svg/1920px-Flag_of_Greece.svg.png" - }, - "Iceland": { - "text": "", - "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/ce/Flag_of_Iceland.svg/1280px-Flag_of_Iceland.svg.png" - }, - "India": { - "text": "", - "flag": "https://upload.wikimedia.org/wikipedia/en/thumb/4/41/Flag_of_India.svg/1920px-Flag_of_India.svg.png" - }, - "Indonesia": { - "text": "", - "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9f/Flag_of_Indonesia.svg/1920px-Flag_of_Indonesia.svg.png" - }, - "Ireland": { - "text": "", - "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/45/Flag_of_Ireland.svg/1920px-Flag_of_Ireland.svg.png" - }, - "Italy": { - "text": "", - "flag": "https://upload.wikimedia.org/wikipedia/en/thumb/0/03/Flag_of_Italy.svg/1920px-Flag_of_Italy.svg.png" - }, - "Mexico": { - "text": "", - "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fc/Flag_of_Mexico.svg/1920px-Flag_of_Mexico.svg.png" - }, - "New Zealand": { - "text": "", - "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3e/Flag_of_New_Zealand.svg/1920px-Flag_of_New_Zealand.svg.png" - }, - "Norway": { - "text": "", - "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d9/Flag_of_Norway.svg/1280px-Flag_of_Norway.svg.png" - }, - "Peru": { - "text": "", - "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/cf/Flag_of_Peru.svg/1920px-Flag_of_Peru.svg.png" - }, - "Portugal": { - "text": "", - "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5c/Flag_of_Portugal.svg/1920px-Flag_of_Portugal.svg.png" - }, - "Scotland": { - "text": "", - "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/10/Flag_of_Scotland.svg/1920px-Flag_of_Scotland.svg.png" - }, - "Slovenia": { - "text": "", - "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f0/Flag_of_Slovenia.svg/1920px-Flag_of_Slovenia.svg.png" - }, - "South Africa": { - "text": "", - "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/af/Flag_of_South_Africa.svg/1920px-Flag_of_South_Africa.svg.png" - }, - "Spain": { - "text": "", - "flag": "https://upload.wikimedia.org/wikipedia/en/thumb/9/9a/Flag_of_Spain.svg/1920px-Flag_of_Spain.svg.png" - }, - "Sweden": { - "text": "", - "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4c/Flag_of_Sweden.svg/1920px-Flag_of_Sweden.svg.png" - }, - "Switzerland": { - "text": "", - "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/08/Flag_of_Switzerland_%28Pantone%29.svg/1024px-Flag_of_Switzerland_%28Pantone%29.svg.png" - }, - "Turkey": { - "text": "", - "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b4/Flag_of_Turkey.svg/1920px-Flag_of_Turkey.svg.png" - }, - "United States": { - "text": "", - "flag": "https://upload.wikimedia.org/wikipedia/en/thumb/a/a4/Flag_of_the_United_States.svg/1920px-Flag_of_the_United_States.svg.png" - }, - "Vietnam": { - "text": "", - "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/21/Flag_of_Vietnam.svg/1920px-Flag_of_Vietnam.svg.png" - }, - "Wales": { - "text": "", - "flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a9/Flag_of_Wales_%281959%E2%80%93present%29.svg/1920px-Flag_of_Wales_%281959%E2%80%93present%29.svg.png" - } -} diff --git a/bot/resources/valentines/valentine_facts.json b/bot/resources/valentines/valentine_facts.json deleted file mode 100644 index e6f826c3..00000000 --- a/bot/resources/valentines/valentine_facts.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "whois": "Saint Valentine, officially Saint Valentine of Rome, was a widely recognized 3rd-century christian saint, commemorated on February 14. He was a priest and bishop, ministering persecuted Christians in the Roman Empire, and is associated with a tradition of courtly love since the High Middle Ages, a period commenced around the year 1000AD and lasting until around 1250AD. He was martyred and buried at a Christian cemetery on the Via Flaminia on February 14.\n\nThere are a bunch of inconsistencies in the identification of the saint, however there are evidences for 3 saints that appear in connection with February 14. One of them, Saint Valentine of Terni, is believed to be the one associated with a vision restoration miracle, which happening during his imprisonment. In that, he restored the eyesight of his jailer's daughter, and, on the evening before his execution, supposedly sent her a letter signed with 'Your Valentine' (tuum valentinum). This makes this saint the one we today associate with Saint Valentine's Day.\n\nThe artist Cicero Moraes attempted a facial reconstruction of Saint Valentine, which can be seen in the thumbnail.", - "titles": [ - "\u2764 Facts \u00e1 la carte \u2764", - "\u2764 Would you like some cheese with your wi... facts? \u2764", - "\u2764 Facts to raise your pulse \u2764", - "\u2764 Love Facts, Episode #42 \u2764", - "\u2764 It's a fact not a fact, duh \u2764", - "\u2764 Candlelight din... facts! \u2764" - ], - "text": [ - "The expression 'From your Valentine' derives from a legend in which Saint Valentine, imprisoned after persecution and not wanting to convert to Roman paganism, performed a miracle on Julia, his jailer Asterius's blind daughter, restoring her eyesight. On the evening before his execution, he is supposed to have written a letter to the jailers daughter, signing as 'Your Valentine' (tuum valentinum).", - "Valentine's Day wasn't really associated with anything romantic, until the 14th century England where it's association with romantic love had begun from within the circle of Geoffrey Chaucer, a famous english poet and author, also called 'Father of English literature' for his work. He is best known for 'The Caunterbury Tales', a collection of 24 stories, which are presented as part of a story-telling contest by a group of pilgrims on their travel from London to Canterbury.", - "It's only been roughly 300 years, that the Valentine's Day evolved into what we know today.", - "The wide usage of hearts on Valentine's day derived from a legend, in which Saint Valentine cut hearts from parchment, giving them to persecuted Christians and soldiers married by him. He did that \"to remind these man of their vows and God's love\"", - "In 1797, a British publisher developed \"The Young Man's Valentine Writer\", to assist young men in composing their own sentimental verses to ladies they felt attracted to.", - "If you've never gotten any handwritten Valentine cards, this may be due to the fact, that in the 19th century, handwritten notes have given away to mass-produced greeting cards.", - "In 1868, a British chocolate company called Cadbury created so called 'Fancy Boxes', which essentially were a decorated box of chocolates in the shape of a heart. This set a trend, such that these boxes were quickly associated with Valentine's Day.", - "Roses are red,\nviolet's are blue,\nI can't rhyme,\nbut I still do.\n\u200b\nThis poem in particular,\nit will stay forever,\nderives from The Faerie Queene,\nan epic poem you probably have never seen.\n\n\"She bath'd with roses red, and violets blew,\nAnd all the sweetest flowres, that in the forrest grew.\"\n\nThese verses, with most immense sway,\nlead to the poem we still hear today.", - "The earliest Valentine poem known is a rondeau, a form of medieval/renaissance French poetry, composed by Charles, Duke of Orl\u00e9ans to his wife:\n\n\"Je suis desja d'amour tann\u00e9,\nMa tres doulce Valentin\u00e9e\"", - "There's a form of cryptological communication called 'Floriography', in which you communicate through flowers. Meaning has been attributed to flowers for thousands of years, and some form of floriography has been practiced in traditional cultures throughout Europe, Asia, and Africa. Here are some meanings for roses you might want to take a look at, if you plan on gifting your loved one a bouquet of roses on Valentine's Day:\n\u200b\nRed: eternal love\nPink: young, developing love\nWhite: innocence, fervor, loyalty\nOrange: happiness, security\nViolet: love at first sight\nBlue: unfulfilled longing, quiet desire\nYellow: friendship, jealousy, envy, infidelity\nBlack: unfulfilled longing, quiet desire, grief, hatred, misfortune, death", - "Traditionally, young girls in the U.S. and the U.K. believed they could tell what type of man they would marry depending on the type of bird they saw first on Valentine's Day. If they saw a blackbird, they would marry a clergyman, a robin redbreast indicated a sailor, and a goldfinch indicated a rich man. A sparrow meant they would marry a farmer, a bluebird indicated a happy man, and a crossbill meant an argumentative man. If they saw a dove, they would marry a good man, but seeing a woodpecker meant they would not marry at all." - ] -} diff --git a/bot/resources/valentines/zodiac_compatibility.json b/bot/resources/valentines/zodiac_compatibility.json deleted file mode 100644 index ea9a7b37..00000000 --- a/bot/resources/valentines/zodiac_compatibility.json +++ /dev/null @@ -1,262 +0,0 @@ -{ - "Aries":[ - { - "Zodiac" : "Sagittarius", - "description" : "The Archer is one of the most compatible signs Aries should consider when searching out relationships that will bear fruit. Sagittarians share a certain love of freedom with Aries that will help the two of them conquer new territory together.", - "compatibility_score" : "87%" - }, - { - "Zodiac" : "Leo", - "description" : "Leo is the center of attention and typically the life of the party, but can also be courageous, bold, and powerful. These two signs share a gregarious nature that helps them walk life's path in sync. They may vie occasionally to see which one leads the way, but all things considered, they share a great capacity for compatibility.", - "compatibility_score" : "83%" - }, - { - "Zodiac" : "Aquarius", - "description" : "Aquarius' need for personal space dovetails nicely with Aries' love of freedom. This doesn't mean that the Ram and the Water Bearer can't forge a deep bond; in fact, quite the opposite. Their mutual respect for one another's needs and space leaves room for both to grow on their own and together.", - "compatibility_score" : "68%" - }, - { - "Zodiac" : "Gemini", - "description" : "The Twins are known for their adaptability, a trait that easily follows Aries' need to lead their closest companions down new paths. Geminis are also celebrated for their personal charm and intellect, traits the discerning Ram is also capable of fully appreciating.", - "compatibility_score" : "74%" - } - ], - "Taurus":[ - { - "Zodiac" : "Virgo", - "description" : "Although these signs have their set of differences, the Virgo Taurus compatibility is usually pretty strong. This is because both the signs want the same thing ultimately and have generally synchronous ways of reaching those points. This helps them complement each other and create a healthy relationship between them.", - "compatibility_score" : "73%" - }, - { - "Zodiac" : "Capricorn", - "description" : "A great compatibility is seen in this match as far as the philosophical and spiritual aspects of life are concerned. Taurus and Capricorn have a practical approach towards life. The ambitions and calmness of the Goat will attract the Bull, who will attract the former with his strong determination.The compatibility between these two zodiac signs is at the greatest height due to their mutual understanding, faith and consonance.", - "compatibility_score" : "89%" - }, - { - "Zodiac" : "Pisces", - "description" : "This relationship will survive the test of time if both parties involved have unbreakable trust in each other and nurture that connection they have painstakingly built over the years. They must remember to be honest and committed to their partner through all times.If natural communication flows between them like clockwork, this will be a beautiful love story with a prominent tag of 'happily-ever-after' pinned right to it!", - "compatibility_score" : "88%" - }, - { - "Zodiac" : "Cancer", - "description" : "This is a peaceful union of two reliable and kind souls. It is one of the strong zodiac pairings in terms of compatibility, and certainly has a real chance of lasting a lifetime in astrological charts. If Taurus man and Cancer woman and vice-versa are mature enough to handle the occasional friction, which they usually are, their bond will transcend all boundaries and is likely to grow in strength through the years.", - "compatibility_score" : "91%" - } - ], - "Gemini":[ - { - "Zodiac" : "Aries", - "description" : "The theorem of astrology says that Aries and Gemini have a zero tolerance for boredom and will at once get rid of anything dull. An Arian will let a Geminian enjoy his personal freedom and the Gemini will respect his individuality.", - "compatibility_score" : "74%" - }, - { - "Zodiac" : "Leo", - "description" : "Gemini and Leo love match that can go through extravagant heights and crushing lows, but one is sure to leave both of them breathless. This is a pair that forms one of the most exciting relationships of the zodiac chart, but the converse is equally true; they are vulnerable to experiencing terrible lows.", - "compatibility_score" : "82%" - }, - { - "Zodiac" : "Libra", - "description" : "It is relatively easier for them to adjust to each other as while they might have different outlooks to some things in life, they rarely collide or crash against one another. Hence, it is easy for them to go forward in harmony, forming a formidable bond in the process.", - "compatibility_score" : "78%" - }, - { - "Zodiac" : "Aquarius", - "description" : "This is one of the most successful pairs of the zodiac chart.If they manage to smooth this solitary wrinkle, this will be a beautiful union for both the parties involved. It will turn into a gift that will keep giving happiness, contentment and encouragement to the air signs.", - "compatibility_score" : "91%" - } - ], - "Cancer":[ - { - "Zodiac" : "Taurus", - "description" : "The Cancer Taurus zodiac relationship compatibility is strong because of their mutual love for safety, stability, and comfort. Their mutual understanding will always be powerful, which will be the pillar of strength of their relationship.", - "compatibility_score" : "91%" - }, - { - "Zodiac" : "Scorpio", - "description" : "The Cancer man and Scorpio woman and vice-versa connect on an intuitive level and will find refuge in each other from the outset of the relationship.", - "compatibility_score" : "79%" - }, - { - "Zodiac" : "Virgo", - "description" : "This is a match made in heaven! Very few zodiac combinations work as well as Cancer and Virgo. Their blissful union is one which is generally filled with memories of happiness, contentment, loyalty, and harmony. They will build an extremely strong bond with each other that can pass all the tests of time and emerge stronger after every challenge that is thrown in its way!", - "compatibility_score" : "77%" - } - ], - "Leo":[ - { - "Zodiac" : "Aries", - "description" : "A Leo is generous and an Arian is open to life. Sharing the same likes and dislikes, they both crave for fun, romance and excitement. A Leo respects an Arian's need for freedom because an Arian does not interfere much in the life of a Leo. Aries will love the charisma and ideas of the Leo.", - "compatibility_score" : "83%" - }, - { - "Zodiac" : "Gemini", - "description" : "This is one of the most compatible pairings in the zodiac chart and if the minor friction is handled with maturity by both the parties, the Leo and Gemini compatibility relationship can last a lifetime. A lifetime, which will be filled with love, happiness, and success.", - "compatibility_score" : "82%" - }, - { - "Zodiac" : "Libra", - "description" : "he thing that attributes for the greater compatibility of this love match is that both the signs like to complement each other. Leo and Libra love to attend social gatherings. They like to engage themselves in romance and act as lovers. This love match is strong enough to face adverse situations due to their opposite individual characters. This pair likes to enjoy candlelit dinners, long drives, dancing", - "compatibility_score" : "75%" - }, - { - "Zodiac" : "Sagittarius", - "description" : "The key for this relationship to work is that both Leo star sign and Sagittarius star sign natives should know where they want it to go in the long run. If they are ready for a long-time commitment and are mutually aware of the same, they will form a brilliant and buoyant bond together. It can last for a lifetime and will be characterized by the excitement, love, happiness, and unending loyalty that Leo and Sagittarius will bring to each other’s life.", - "compatibility_score" : "75%" - } - ], - "Virgo":[ - { - "Zodiac" : "Taurus", - "description" : "Although these signs have their set of differences, the Virgo Taurus compatibility is usually pretty strong. This is because both the signs want the same thing ultimately and have generally synchronous ways of reaching those points. This helps them complement each other and create a healthy relationship between them.", - "compatibility_score" : "73%" - }, - { - "Zodiac" : "Cancer", - "description" : "This is one of the few zodiac compatibilities that look great not only on paper, but also in reality. The shared outlook that Cancer and Virgo have towards life makes their relationship a positive, reliable, and beautiful entity for both the sides.", - "compatibility_score" : "77%" - }, - { - "Zodiac" : "Scorpio", - "description" : "The Virgo Scorpio love match definitely has something unique to offer to the zodiac world, and to both the parties involved in this pair. If they manage to control their negative traits, this relationship truly has the chance to survive everything life throws at it.", - "compatibility_score" : "76%" - }, - { - "Zodiac" : "Capricorn", - "description" : "The compatibility of this match is quite well as both rely on each other and possess the maturity to understand their individual outlook. Capricorn plans in advance and steadily reaches his goal and a Virgo has faith in his abilities and skills.", - "compatibility_score" : "77%" - } - ], - "Libra":[ - { - "Zodiac" : "Leo", - "description" : "Libra and Leo love match can work well for both the partners and truly help them learn from each other and grow individually, as well as together. Libra and Leo, when in the right frame of mind, form a formidable couple that attracts admiration and respect everywhere it goes.", - "compatibility_score" : "75%" - }, - { - "Zodiac" : "Aquarius", - "description" : "Libra Aquarius Relationship has a good chance of working because of the immense understanding that both the zodiac signs have for each other. They are likely to fight less as Libra believes in avoiding conflict and Aquarius usually is an extremely open-minded and non-judgmental sign.", - "compatibility_score" : "68%" - }, - { - "Zodiac" : "Gemini", - "description" : "It is relatively easier for them to adjust to each other as while they might have different outlooks to some things in life, they rarely collide or crash against one another. Hence, it is easy for them to go forward in harmony, forming a formidable bond in the process.", - "compatibility_score" : "78%" - }, - { - "Zodiac" : "sagittarius", - "description" : "If Sagittarius and Libra want to make it with each other, they must give themselves the time to understand each other fully. Rushing through the relationship will only need to problems in the future and that can be avoided if both the parties are careful not to get too ahead of themselves at any point.", - "compatibility_score" : "71%" - } - ], - "Scorpio":[ - { - "Zodiac" : "Cancer", - "description" : "This union is not unusual, but will take a fair share of work in the start. A strong foundation of clear cut communication is mandatory to make this a loving and stress free relationship!", - "compatibility_score" : "79%" - }, - { - "Zodiac" : "Virgo", - "description" : "Very rarely does it so happen that a pairing can be so perfect and perfectly horrid at the same time. As such, it is imperative that the individuals involved take time before they jump into a serious commitment. Knowing these two zodiacs, commitments last for a lifetime.", - "compatibility_score" : "76%" - }, - { - "Zodiac" : "Capricorn", - "description" : "The relationship of Scorpio and Capricorn can be inspiring for both partners to search for the truth, dig up under their family tree and deal with any unresolved karma and debt. They are both deep and don’t take things lightly, and this will help them build a strong foundation for a relationship that can last for a long time.", - "compatibility_score" : "72%" - }, - { - "Zodiac" : "Pisces", - "description" : "Scorpio-Pisces is one of the most compatible signs of the zodiac calendar. They have a gamut of intense emotions that are consistently in sync with each other. Only if the Scorpion learns to let it go once in a while and the Pisces develops a sense of self-assurance, it will be a successful partnership that will meet its short-term as well as long-term goals.", - "compatibility_score" : "81%" - } - ], - "Sagittarius":[ - { - "Zodiac" : "Aries", - "description" : "Sagittarius and Aries can make a very compatible pair. Their relationship will have a lot of passion, enthusiasm, and energy. These are very good traits to make their relationship deeper and stronger. Both Aries and Sagittarius will enjoy each other's company and their energy level rises as the relationship grows. Both will support and help in fighting hardships and failures.", - "compatibility_score" : "87%" - }, - { - "Zodiac" : "Leo", - "description" : "While there might be a few blips in the Sagittarius Leo relationship, they are certainly one of the most compatible couples when it comes to astrological studies. They can bring the best out of each other and shine in the light of their positive all exciting relationship.", - "compatibility_score" : "75%" - }, - { - "Zodiac" : "Libra", - "description" : "The connectivity between Sagittarians and Librans is amazing. Librans are inclined towards maintaining balance and harmony. Sagittarians are intelligent and fun loving. But, Librans have a strange trait of transiting from one phase of emotion to other.", - "compatibility_score" : "71%" - }, - { - "Zodiac" : "Aquarius", - "description" : "This is a combination where the positives outweigh the negatives by a considerable margin. As long as this pair builds and retains their passion for each other in the relationship, it will be a hit that can pass every test that is thrown at it.The Sagittarius Aquarius compatibility is one of a kind and can go the distance in most cases.", - "compatibility_score" : "83%" - } - ], - "Capricorn":[ - { - "Zodiac" : "Taurus", - "description" : "This is one of the most grounded and reliable bonds of the zodiac chart. If Capricorn and Taurus do find a way to handle their minor issues, they have a good chance of making it together and that too, in a happy, peaceful, and healthy relationship.", - "compatibility_score" : "89%" - }, - { - "Zodiac" : "Virgo", - "description" : "Virgo and Capricorn will connect to each other on a visceral level and will find enduring stability in their relationship. Since this bond is something they can rely on, they tend to ease up individually and thus, are likely to be more relaxed and happier in life.", - "compatibility_score" : "77%" - }, - { - "Zodiac" : "Scorpio", - "description" : "Though they have a couple of blips in the relationship, the combination of Scorpion and Capricorn is a match with powerful compatibility. They will shine with each other, develop each other, and build a beautiful bond with each other. If and when the two decide to commit to each other, their relationship is one that is likely to go the distance nine times out of ten!", - "compatibility_score" : "70%" - }, - { - "Zodiac" : "Pisces", - "description" : "Pisces and Capricorn signs have primary difference of qualities, but their union is aided seamlessly due to that very discrepancy of their personas. It is a highly compatible relationship that can go a long way with mutual respect, amicable understanding and immense trust between the concerned parties.", - "compatibility_score" : "76%" - } - ], - "Aquarius":[ - { - "Zodiac" : "Aries", - "description" : "The relationship of Aries and Aquarius is very exciting, adventurous and interesting. They will enjoy each other's company as both of them love fun and freedom.This is a couple that lacks tenderness. They are not two brutes who let their relationship fade as soon as their passion does.", - "compatibility_score" : "72%" - }, - { - "Zodiac" : "Gemini", - "description" : "If you want to see a relationship that is full of conversation, ideas and \"eureka moments\", you observe the Aquarius Gemini relationship. They will enjoy a camaraderie that you won't find in most zodiac relationships.", - "compatibility_score" : "85%" - }, - { - "Zodiac" : "Libra", - "description" : "The couple will share a lot of similar qualities and will enjoy the relationship. Both of them love to socialize with people and to make new friends.", - "compatibility_score" : "68%" - }, - { - "Zodiac" : "Sagittarius", - "description" : "These two will get together when it is time for both of them to go through a change in their lives or leave a partner they feel restricted with. Their relationship is often a shiny beacon to everyone around them because it gives priority to the future and brings hope of a better time.", - "compatibility_score" : "83%" - } - ], - "Pisces":[ - { - "Zodiac" : "Taurus", - "description" : "This relationship will survive the test of time if both parties involved have unbreakable trust in each other and nurture that connection they have painstakingly built over the years. They must remember to be honest and committed to their partner through all times.If natural communication flows between them like clockwork, this will be a beautiful love story with a prominent tag of ‘happily-ever-after’ pinned right to it!", - "compatibility_score" : "88%" - }, - { - "Zodiac" : "Cancer", - "description" : "The Pisces Cancer union is a beautiful one. Both zodiacs are normally blessed with appealing looks and attractive minds. They are both prone to be drawn to each other. Mostly, for very valid reasons. However, both the zodiac star signs need to make sure that they do not let their love overpower them.", - "compatibility_score" : "72%" - }, - { - "Zodiac" : "Scorpio", - "description" : "Though there are a few problems that might test this relationship, these star sign compatibility of two water signs has many features that can help it survive. Pisces and Scorpio are both passionate in their own ways and thus, end up striking the perfect rhythm with each other.If and when they decide to commit to the relationship, they can script a special story of camaraderie and bliss with each other.", - "compatibility_score" : "81%" - }, - { - "Zodiac" : "Capricorn", - "description" : "A relationship between Capricorn and Pisces tells a story about possibilities of inspiration. If someone like Capricorn can be pulled into a crazy love story, exciting and unpredictable, this must be done by Pisces. In return, Capricorn will offer their Pisces partner stability, peace and some rest from their usual emotional tornadoes. There is a fine way in which Capricorn can help Pisces be more realistic and practical, while feeling more cheerful and optimistic themselves.", - "compatibility_score" : "76%" - } - ] - -} diff --git a/bot/resources/valentines/zodiac_explanation.json b/bot/resources/valentines/zodiac_explanation.json deleted file mode 100644 index 33864ea5..00000000 --- a/bot/resources/valentines/zodiac_explanation.json +++ /dev/null @@ -1,122 +0,0 @@ -{ - "Aries": { - "start_at": "2020-03-21", - "end_at": "2020-04-19", - "About": "Amazing people born between **March 21** to **April 19**. Aries loves to be number one, so it\u2019s no surprise that these audacious rams are the first sign of the zodiac. Bold and ambitious, Aries dives headfirst into even the most challenging situations.", - "Motto": "***\u201cWhen you know yourself, you're empowered. When you accept yourself, you're invincible.\u201d***", - "Strengths": "Courageous, determined, confident, enthusiastic, optimistic, honest, passionate.", - "Weaknesses": "Impatient, moody, short-tempered, impulsive, aggressive.", - "full_form": "__**A**__ssertive, __**R**__efreshing, __**I**__ndependent, __**E**__nergetic, __**S**__exy", - "url": "https://www.horoscope.com/images-US/signs/profile-aries.png" - }, - "Taurus": { - "start_at": "2020-04-20", - "end_at": "2020-05-20", - "About": "Amazing people born between **April 20** to **May 20**. Taurus is an earth sign represented by the bull. Like their celestial spirit animal, Taureans enjoy relaxing in serene, bucolic environments surrounded by soft sounds, soothing aromas, and succulent flavors", - "Motto": "***\u201cNothing worth having comes easy.\u201d***", - "Strengths": "Reliable, patient, practical, devoted, responsible, stable.", - "Weaknesses": "Stubborn, possessive, uncompromising.", - "full_form": "__**T**__railblazing, __**A**__mbitious, __**U**__nwavering, __**R**__eliable, __**U**__nderstanding, __**S**__table", - "url": "https://www.horoscope.com/images-US/signs/profile-taurus.png" - }, - "Gemini": { - "start_at": "2020-05-21", - "end_at": "2020-06-20", - "About": "Amazing people born between **May 21** to **June 20**. Have you ever been so busy that you wished you could clone yourself just to get everything done? That\u2019s the Gemini experience in a nutshell. Appropriately symbolized by the celestial twins, this air sign was interested in so many pursuits that it had to double itself.", - "Motto": "***\u201cI manifest my reality.\u201d***", - "Strengths": "Gentle, affectionate, curious, adaptable, ability to learn quickly and exchange ideas.", - "Weaknesses": "Nervous, inconsistent, indecisive.", - "full_form": "__**G**__enerous, __**E**__motionally in tune, __**M**__otivated, __**I**__maginative, __**N**__ice, __**I**__ntelligent", - "url": "https://www.horoscope.com/images-US/signs/profile-gemini.png" - }, - "Cancer": { - "start_at": "2020-06-21", - "end_at": "2020-07-22", - "About": "Amazing people born between **June 21 ** to **July 22**. Cancer is a cardinal water sign. Represented by the crab, this crustacean seamlessly weaves between the sea and shore representing Cancer\u2019s ability to exist in both emotional and material realms. Cancers are highly intuitive and their psychic abilities manifest in tangible spaces: For instance, Cancers can effortlessly pick up the energies in a room.", - "Motto": "***\u201cI feel, therefore I am.\u201d***", - "Strengths": "Tenacious, highly imaginative, loyal, emotional, sympathetic, persuasive.", - "Weaknesses": "Moody, pessimistic, suspicious, manipulative, insecuremoody, pessimistic, suspicious, manipulative, insecure.", - "full_form": "__**C**__aring, __**A**__mbitious, __**N**__ourishing, __**C**__reative, __**E**__motionally intelligent, __**R**__esilient", - "url": "https://www.horoscope.com/images-US/signs/profile-cancer.png" - }, - "Leo": { - "start_at": "2020-07-23", - "end_at": "2020-08-22", - "About": "Amazing people born between **July 23** to **August 22**. Roll out the red carpet because Leo has arrived. Leo is represented by the lion and these spirited fire signs are the kings and queens of the celestial jungle. They\u2019re delighted to embrace their royal status: Vivacious, theatrical, and passionate, Leos love to bask in the spotlight and celebrate themselves.", - "Motto": "***\u201cIf you know the way, go the way and show the way\u2014you're a leader.\u201d***", - "Strengths": "Creative, passionate, generous, warm-hearted, cheerful, humorous.", - "Weaknesses": "Arrogant, stubborn, self-centered, lazy, inflexible.", - "full_form": "__**L**__eaders, __**E**__nergetic, __**O**__ptimistic", - "url": "https://www.horoscope.com/images-US/signs/profile-leo.png" - }, - "Virgo": { - "start_at": "2020-08-23", - "end_at": "2020-09-22", - "About": "Amazing people born between **August 23** to **September 22**. Virgo is an earth sign historically represented by the goddess of wheat and agriculture, an association that speaks to Virgo\u2019s deep-rooted presence in the material world. Virgos are logical, practical, and systematic in their approach to life. This earth sign is a perfectionist at heart and isn\u2019t afraid to improve skills through diligent and consistent practice.", - "Motto": "***\u201cMy best can always be better.\u201d***", - "Strengths": "Loyal, analytical, kind, hardworking, practical.", - "Weaknesses": "Shyness, worry, overly critical of self and others, all work and no play.", - "full_form": "__**V**__irtuous, __**I**__ntelligent, __**R**__esponsible, __**G**__enerous, __**O**__ptimistic", - "url": "https://www.horoscope.com/images-US/signs/profile-virgo.png" - }, - "Libra": { - "start_at": "2020-09-23", - "end_at": "2020-10-22", - "About": "Amazing people born between **September 23** to **October 22**. Libra is an air sign represented by the scales (interestingly, the only inanimate object of the zodiac), an association that reflects Libra's fixation on balance and harmony. Libra is obsessed with symmetry and strives to create equilibrium in all areas of life.", - "Motto": "***\u201cNo person is an island.\u201d***", - "Strengths": "Cooperative, diplomatic, gracious, fair-minded, social.", - "Weaknesses": "Indecisive, avoids confrontations, will carry a grudge, self-pity.", - "full_form": "__**L**__oyal, __**I**__nquisitive, __**B**__alanced, __**R**__esponsible, __**A**__ltruistic", - "url": "https://www.horoscope.com/images-US/signs/profile-libra.png" - }, - "Scorpio": { - "start_at": "2020-10-23", - "end_at": "2020-11-21", - "About": "Amazing people born between **October 23** to **November 21**. Scorpio is one of the most misunderstood signs of the zodiac. Because of its incredible passion and power, Scorpio is often mistaken for a fire sign. In fact, Scorpio is a water sign that derives its strength from the psychic, emotional realm.", - "Motto": "***\u201cYou never know what you are capable of until you try.\u201d***", - "Strengths": "Resourceful, brave, passionate, stubborn, a true friend.", - "Weaknesses": "Distrusting, jealous, secretive, violent.", - "full_form": "__**S**__eductive, __**C**__erebral, __**O**__riginal, __**R**__eactive, __**P**__assionate, __**I**__ntuitive, __**O**__utstanding", - "url": "https://www.horoscope.com/images-US/signs/profile-scorpio.png" - }, - "Sagittarius": { - "start_at": "2020-11-22", - "end_at": "2020-12-21", - "About": "Amazing people born between **November 22** to **December 21**. Represented by the archer, Sagittarians are always on a quest for knowledge. The last fire sign of the zodiac, Sagittarius launches its many pursuits like blazing arrows, chasing after geographical, intellectual, and spiritual adventures.", - "Motto": "***\u201cTowering genius disdains a beaten path.\u201d***", - "Strengths": "Generous, idealistic, great sense of humor.", - "Weaknesses": "Promises more than can deliver, very impatient, will say anything no matter how undiplomatic.", - "full_form": "__**S**__eductive, __**A**__dventurous, __**G**__rateful, __**I**__ntelligent, __**T**__railblazing, __**T**__enacious adept, __**A**__dept, __**R**__esponsible, __**I**__dealistic, __**U**__nparalled, __**S**__ophisticated", - "url": "https://www.horoscope.com/images-US/signs/profile-sagittarius.png" - }, - "Capricorn": { - "start_at": "2020-12-22", - "end_at": "2021-01-19", - "About": "Amazing people born between **December 22** to **January 19**. The last earth sign of the zodiac, Capricorn is represented by the sea goat, a mythological creature with the body of a goat and tail of a fish. Accordingly, Capricorns are skilled at navigating both the material and emotional realms.", - "Motto": "***\u201cI can succeed at anything I put my mind to.\u201d***", - "Strengths": "Responsible, disciplined, self-control, good managers.", - "Weaknesses": "Know-it-all, unforgiving, condescending, expecting the worst.", - "full_form": "__**C**__onfident, __**A**__nalytical, __**P**__ractical, __**R**__esponsible, __**I**__ntelligent, __**C**__aring, __**O**__rganized, __**R**__ealistic, __**N**__eat", - "url": "https://www.horoscope.com/images-US/signs/profile-capricorn.png" - }, - "Aquarius": { - "start_at": "2020-01-20", - "end_at": "2020-02-18", - "About": "Amazing people born between **January 20** to **February 18**. Despite the \u201caqua\u201d in its name, Aquarius is actually the last air sign of the zodiac. Aquarius is represented by the water bearer, the mystical healer who bestows water, or life, upon the land. Accordingly, Aquarius is the most humanitarian astrological sign.", - "Motto": "***\u201cThere is no me, there is only we.\u201d***", - "Strengths": "Progressive, original, independent, humanitarian.", - "Weaknesses": "Runs from emotional expression, temperamental, uncompromising, aloof.", - "full_form": "__**A**__nalytical, __**Q**__uirky, __**U**__ncompromising, __**A**__ction-focused, __**R**__espectful, __**I**__ntelligent, __**U**__nique, __**S**__incere", - "url": "https://www.horoscope.com/images-US/signs/profile-aquarius.png" - }, - "Pisces": { - "start_at": "2020-02-19", - "end_at": "2020-03-20", - "About": "Amazing people born between **February 19** to **March 20**. Pisces, a water sign, is the last constellation of the zodiac. It's symbolized by two fish swimming in opposite directions, representing the constant division of Pisces' attention between fantasy and reality. As the final sign, Pisces has absorbed every lesson \u2014 the joys and the pain, the hopes and the fears \u2014 learned by all of the other signs.", - "Motto": "***\u201cI have a lot of love to give, it only takes a little patience and those worth giving it all to.\u201d***", - "Strengths": "Compassionate, artistic, intuitive, gentle, wise, musical.", - "Weaknesses": "Fearful, overly trusting, sad, desire to escape reality, can be a victim or a martyr.", - "full_form": "__**P**__sychic, __**I**__ntelligent, __**S**__urprising, __**C**__reative, __**E**__motionally-driven, __**S**__ensitive", - "url": "https://www.horoscope.com/images-US/signs/profile-pisces.png" - } -} -- cgit v1.2.3 From 76c143dd5ced4f1d5aafbc389f94cd1c0189305b Mon Sep 17 00:00:00 2001 From: Janine vN Date: Sat, 4 Sep 2021 23:43:25 -0400 Subject: Move Pride to Holidays folder Moves the Pride features to the Holidays folder. Corrected the paths to reflect this change. --- bot/exts/holidays/pride/__init__.py | 0 bot/exts/holidays/pride/drag_queen_name.py | 26 ++ bot/exts/holidays/pride/pride_anthem.py | 51 +++ bot/exts/holidays/pride/pride_facts.py | 99 +++++ bot/exts/holidays/pride/pride_leader.py | 117 ++++++ bot/exts/pride/drag_queen_name.py | 26 -- bot/exts/pride/pride_anthem.py | 51 --- bot/exts/pride/pride_facts.py | 99 ----- bot/exts/pride/pride_leader.py | 117 ------ bot/resources/holidays/pride/anthems.json | 455 +++++++++++++++++++++ bot/resources/holidays/pride/drag_queen_names.json | 249 +++++++++++ bot/resources/holidays/pride/facts.json | 34 ++ bot/resources/holidays/pride/flags/agender.png | Bin 0 -> 2044 bytes bot/resources/holidays/pride/flags/androgyne.png | Bin 0 -> 2382 bytes bot/resources/holidays/pride/flags/aromantic.png | Bin 0 -> 2990 bytes bot/resources/holidays/pride/flags/asexual.png | Bin 0 -> 967 bytes bot/resources/holidays/pride/flags/bigender.png | Bin 0 -> 9662 bytes bot/resources/holidays/pride/flags/bisexual.png | Bin 0 -> 5065 bytes bot/resources/holidays/pride/flags/demiboy.png | Bin 0 -> 2430 bytes bot/resources/holidays/pride/flags/demigirl.png | Bin 0 -> 985 bytes bot/resources/holidays/pride/flags/demisexual.png | Bin 0 -> 4728 bytes bot/resources/holidays/pride/flags/gay.png | Bin 0 -> 2721 bytes bot/resources/holidays/pride/flags/genderfluid.png | Bin 0 -> 1003 bytes bot/resources/holidays/pride/flags/genderqueer.png | Bin 0 -> 934 bytes bot/resources/holidays/pride/flags/intersex.png | Bin 0 -> 5064 bytes bot/resources/holidays/pride/flags/lesbian.png | Bin 0 -> 2129 bytes bot/resources/holidays/pride/flags/nonbinary.png | Bin 0 -> 1023 bytes bot/resources/holidays/pride/flags/omnisexual.png | Bin 0 -> 2353 bytes bot/resources/holidays/pride/flags/pangender.png | Bin 0 -> 2424 bytes bot/resources/holidays/pride/flags/pansexual.png | Bin 0 -> 2262 bytes bot/resources/holidays/pride/flags/polyamory.png | Bin 0 -> 2937 bytes bot/resources/holidays/pride/flags/polysexual.png | Bin 0 -> 463 bytes bot/resources/holidays/pride/flags/transgender.png | Bin 0 -> 848 bytes bot/resources/holidays/pride/flags/trigender.png | Bin 0 -> 2481 bytes bot/resources/holidays/pride/gender_options.json | 41 ++ bot/resources/holidays/pride/prideleader.json | 100 +++++ bot/resources/pride/anthems.json | 455 --------------------- bot/resources/pride/drag_queen_names.json | 249 ----------- bot/resources/pride/facts.json | 34 -- bot/resources/pride/flags/agender.png | Bin 2044 -> 0 bytes bot/resources/pride/flags/androgyne.png | Bin 2382 -> 0 bytes bot/resources/pride/flags/aromantic.png | Bin 2990 -> 0 bytes bot/resources/pride/flags/asexual.png | Bin 967 -> 0 bytes bot/resources/pride/flags/bigender.png | Bin 9662 -> 0 bytes bot/resources/pride/flags/bisexual.png | Bin 5065 -> 0 bytes bot/resources/pride/flags/demiboy.png | Bin 2430 -> 0 bytes bot/resources/pride/flags/demigirl.png | Bin 985 -> 0 bytes bot/resources/pride/flags/demisexual.png | Bin 4728 -> 0 bytes bot/resources/pride/flags/gay.png | Bin 2721 -> 0 bytes bot/resources/pride/flags/genderfluid.png | Bin 1003 -> 0 bytes bot/resources/pride/flags/genderqueer.png | Bin 934 -> 0 bytes bot/resources/pride/flags/intersex.png | Bin 5064 -> 0 bytes bot/resources/pride/flags/lesbian.png | Bin 2129 -> 0 bytes bot/resources/pride/flags/nonbinary.png | Bin 1023 -> 0 bytes bot/resources/pride/flags/omnisexual.png | Bin 2353 -> 0 bytes bot/resources/pride/flags/pangender.png | Bin 2424 -> 0 bytes bot/resources/pride/flags/pansexual.png | Bin 2262 -> 0 bytes bot/resources/pride/flags/polyamory.png | Bin 2937 -> 0 bytes bot/resources/pride/flags/polysexual.png | Bin 463 -> 0 bytes bot/resources/pride/flags/transgender.png | Bin 848 -> 0 bytes bot/resources/pride/flags/trigender.png | Bin 2481 -> 0 bytes bot/resources/pride/gender_options.json | 41 -- bot/resources/pride/prideleader.json | 100 ----- 63 files changed, 1172 insertions(+), 1172 deletions(-) create mode 100644 bot/exts/holidays/pride/__init__.py create mode 100644 bot/exts/holidays/pride/drag_queen_name.py create mode 100644 bot/exts/holidays/pride/pride_anthem.py create mode 100644 bot/exts/holidays/pride/pride_facts.py create mode 100644 bot/exts/holidays/pride/pride_leader.py delete mode 100644 bot/exts/pride/drag_queen_name.py delete mode 100644 bot/exts/pride/pride_anthem.py delete mode 100644 bot/exts/pride/pride_facts.py delete mode 100644 bot/exts/pride/pride_leader.py create mode 100644 bot/resources/holidays/pride/anthems.json create mode 100644 bot/resources/holidays/pride/drag_queen_names.json create mode 100644 bot/resources/holidays/pride/facts.json create mode 100644 bot/resources/holidays/pride/flags/agender.png create mode 100644 bot/resources/holidays/pride/flags/androgyne.png create mode 100644 bot/resources/holidays/pride/flags/aromantic.png create mode 100644 bot/resources/holidays/pride/flags/asexual.png create mode 100644 bot/resources/holidays/pride/flags/bigender.png create mode 100644 bot/resources/holidays/pride/flags/bisexual.png create mode 100644 bot/resources/holidays/pride/flags/demiboy.png create mode 100644 bot/resources/holidays/pride/flags/demigirl.png create mode 100644 bot/resources/holidays/pride/flags/demisexual.png create mode 100644 bot/resources/holidays/pride/flags/gay.png create mode 100644 bot/resources/holidays/pride/flags/genderfluid.png create mode 100644 bot/resources/holidays/pride/flags/genderqueer.png create mode 100644 bot/resources/holidays/pride/flags/intersex.png create mode 100644 bot/resources/holidays/pride/flags/lesbian.png create mode 100644 bot/resources/holidays/pride/flags/nonbinary.png create mode 100644 bot/resources/holidays/pride/flags/omnisexual.png create mode 100644 bot/resources/holidays/pride/flags/pangender.png create mode 100644 bot/resources/holidays/pride/flags/pansexual.png create mode 100644 bot/resources/holidays/pride/flags/polyamory.png create mode 100644 bot/resources/holidays/pride/flags/polysexual.png create mode 100644 bot/resources/holidays/pride/flags/transgender.png create mode 100644 bot/resources/holidays/pride/flags/trigender.png create mode 100644 bot/resources/holidays/pride/gender_options.json create mode 100644 bot/resources/holidays/pride/prideleader.json delete mode 100644 bot/resources/pride/anthems.json delete mode 100644 bot/resources/pride/drag_queen_names.json delete mode 100644 bot/resources/pride/facts.json delete mode 100644 bot/resources/pride/flags/agender.png delete mode 100644 bot/resources/pride/flags/androgyne.png delete mode 100644 bot/resources/pride/flags/aromantic.png delete mode 100644 bot/resources/pride/flags/asexual.png delete mode 100644 bot/resources/pride/flags/bigender.png delete mode 100644 bot/resources/pride/flags/bisexual.png delete mode 100644 bot/resources/pride/flags/demiboy.png delete mode 100644 bot/resources/pride/flags/demigirl.png delete mode 100644 bot/resources/pride/flags/demisexual.png delete mode 100644 bot/resources/pride/flags/gay.png delete mode 100644 bot/resources/pride/flags/genderfluid.png delete mode 100644 bot/resources/pride/flags/genderqueer.png delete mode 100644 bot/resources/pride/flags/intersex.png delete mode 100644 bot/resources/pride/flags/lesbian.png delete mode 100644 bot/resources/pride/flags/nonbinary.png delete mode 100644 bot/resources/pride/flags/omnisexual.png delete mode 100644 bot/resources/pride/flags/pangender.png delete mode 100644 bot/resources/pride/flags/pansexual.png delete mode 100644 bot/resources/pride/flags/polyamory.png delete mode 100644 bot/resources/pride/flags/polysexual.png delete mode 100644 bot/resources/pride/flags/transgender.png delete mode 100644 bot/resources/pride/flags/trigender.png delete mode 100644 bot/resources/pride/gender_options.json delete mode 100644 bot/resources/pride/prideleader.json (limited to 'bot') diff --git a/bot/exts/holidays/pride/__init__.py b/bot/exts/holidays/pride/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bot/exts/holidays/pride/drag_queen_name.py b/bot/exts/holidays/pride/drag_queen_name.py new file mode 100644 index 00000000..bd01a603 --- /dev/null +++ b/bot/exts/holidays/pride/drag_queen_name.py @@ -0,0 +1,26 @@ +import json +import logging +import random +from pathlib import Path + +from discord.ext import commands + +from bot.bot import Bot + +log = logging.getLogger(__name__) + +NAMES = json.loads(Path("bot/resources/holidays/pride/drag_queen_names.json").read_text("utf8")) + + +class DragNames(commands.Cog): + """Gives a random drag queen name!""" + + @commands.command(name="dragname", aliases=("dragqueenname", "queenme")) + async def dragname(self, ctx: commands.Context) -> None: + """Sends a message with a drag queen name.""" + await ctx.send(random.choice(NAMES)) + + +def setup(bot: Bot) -> None: + """Load the Drag Names Cog.""" + bot.add_cog(DragNames()) diff --git a/bot/exts/holidays/pride/pride_anthem.py b/bot/exts/holidays/pride/pride_anthem.py new file mode 100644 index 00000000..e8a4563b --- /dev/null +++ b/bot/exts/holidays/pride/pride_anthem.py @@ -0,0 +1,51 @@ +import json +import logging +import random +from pathlib import Path +from typing import Optional + +from discord.ext import commands + +from bot.bot import Bot + +log = logging.getLogger(__name__) + +VIDEOS = json.loads(Path("bot/resources/holidays/pride/anthems.json").read_text("utf8")) + + +class PrideAnthem(commands.Cog): + """Embed a random youtube video for a gay anthem!""" + + def get_video(self, genre: Optional[str] = None) -> dict: + """ + Picks a random anthem from the list. + + If `genre` is supplied, it will pick from videos attributed with that genre. + If none can be found, it will log this as well as provide that information to the user. + """ + if not genre: + return random.choice(VIDEOS) + else: + songs = [song for song in VIDEOS if genre.casefold() in song["genre"]] + try: + return random.choice(songs) + except IndexError: + log.info("No videos for that genre.") + + @commands.command(name="prideanthem", aliases=("anthem", "pridesong")) + async def prideanthem(self, ctx: commands.Context, genre: str = None) -> None: + """ + Sends a message with a video of a random pride anthem. + + If `genre` is supplied, it will select from that genre only. + """ + anthem = self.get_video(genre) + if anthem: + await ctx.send(anthem["url"]) + else: + await ctx.send("I couldn't find a video, sorry!") + + +def setup(bot: Bot) -> None: + """Load the Pride Anthem Cog.""" + bot.add_cog(PrideAnthem()) diff --git a/bot/exts/holidays/pride/pride_facts.py b/bot/exts/holidays/pride/pride_facts.py new file mode 100644 index 00000000..e6ef7108 --- /dev/null +++ b/bot/exts/holidays/pride/pride_facts.py @@ -0,0 +1,99 @@ +import json +import logging +import random +from datetime import datetime +from pathlib import Path +from typing import Union + +import dateutil.parser +import discord +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Channels, Colours, Month +from bot.utils.decorators import seasonal_task + +log = logging.getLogger(__name__) + +FACTS = json.loads(Path("bot/resources/holidays/pride/facts.json").read_text("utf8")) + + +class PrideFacts(commands.Cog): + """Provides a new fact every day during the Pride season!""" + + def __init__(self, bot: Bot): + self.bot = bot + self.daily_fact_task = self.bot.loop.create_task(self.send_pride_fact_daily()) + + @seasonal_task(Month.JUNE) + async def send_pride_fact_daily(self) -> None: + """Background task to post the daily pride fact every day.""" + await self.bot.wait_until_guild_available() + + channel = self.bot.get_channel(Channels.community_bot_commands) + await self.send_select_fact(channel, datetime.utcnow()) + + async def send_random_fact(self, ctx: commands.Context) -> None: + """Provides a fact from any previous day, or today.""" + now = datetime.utcnow() + previous_years_facts = (y for x, y in FACTS.items() if int(x) < now.year) + current_year_facts = FACTS.get(str(now.year), [])[:now.day] + previous_facts = current_year_facts + [x for y in previous_years_facts for x in y] + try: + await ctx.send(embed=self.make_embed(random.choice(previous_facts))) + except IndexError: + await ctx.send("No facts available") + + async def send_select_fact(self, target: discord.abc.Messageable, _date: Union[str, datetime]) -> None: + """Provides the fact for the specified day, if the day is today, or is in the past.""" + now = datetime.utcnow() + if isinstance(_date, str): + try: + date = dateutil.parser.parse(_date, dayfirst=False, yearfirst=False, fuzzy=True) + except (ValueError, OverflowError) as err: + await target.send(f"Error parsing date: {err}") + return + else: + date = _date + if date.year < now.year or (date.year == now.year and date.day <= now.day): + try: + await target.send(embed=self.make_embed(FACTS[str(date.year)][date.day - 1])) + except KeyError: + await target.send(f"The year {date.year} is not yet supported") + return + except IndexError: + await target.send(f"Day {date.day} of {date.year} is not yet support") + return + else: + await target.send("The fact for the selected day is not yet available.") + + @commands.command(name="pridefact", aliases=("pridefacts",)) + async def pridefact(self, ctx: commands.Context, option: str = None) -> None: + """ + Sends a message with a pride fact of the day. + + If "random" is given as an argument, a random previous fact will be provided. + + If a date is given as an argument, and the date is in the past, the fact from that day + will be provided. + """ + if not option: + await self.send_select_fact(ctx, datetime.utcnow()) + elif option.lower().startswith("rand"): + await self.send_random_fact(ctx) + else: + await self.send_select_fact(ctx, option) + + @staticmethod + def make_embed(fact: str) -> discord.Embed: + """Makes a nice embed for the fact to be sent.""" + return discord.Embed( + colour=Colours.pink, + title="Pride Fact!", + description=fact + ) + + +def setup(bot: Bot) -> None: + """Load the Pride Facts Cog.""" + bot.add_cog(PrideFacts(bot)) diff --git a/bot/exts/holidays/pride/pride_leader.py b/bot/exts/holidays/pride/pride_leader.py new file mode 100644 index 00000000..298c9328 --- /dev/null +++ b/bot/exts/holidays/pride/pride_leader.py @@ -0,0 +1,117 @@ +import json +import logging +import random +from pathlib import Path +from typing import Optional + +import discord +from discord.ext import commands +from rapidfuzz import fuzz + +from bot import constants +from bot.bot import Bot + +log = logging.getLogger(__name__) + +PRIDE_RESOURCE = json.loads(Path("bot/resources/holidays/pride/prideleader.json").read_text("utf8")) +MINIMUM_FUZZ_RATIO = 40 + + +class PrideLeader(commands.Cog): + """Gives information about Pride Leaders.""" + + def __init__(self, bot: Bot): + self.bot = bot + + def invalid_embed_generate(self, pride_leader: str) -> discord.Embed: + """ + Generates Invalid Embed. + + The invalid embed contains a list of closely matched names of the invalid pride + leader the user gave. If no closely matched names are found it would list all + the available pride leader names. + + Wikipedia is a useful place to learn about pride leaders and we don't have all + the pride leaders, so the bot would add a field containing the wikipedia + command to execute. + """ + embed = discord.Embed( + color=constants.Colours.soft_red + ) + valid_names = [] + pride_leader = pride_leader.title() + for name in PRIDE_RESOURCE: + if fuzz.ratio(pride_leader, name) >= MINIMUM_FUZZ_RATIO: + valid_names.append(name) + + if not valid_names: + valid_names = ", ".join(PRIDE_RESOURCE) + error_msg = "Sorry your input didn't match any stored names, here is a list of available names:" + else: + valid_names = "\n".join(valid_names) + error_msg = "Did you mean?" + + embed.description = f"{error_msg}\n```\n{valid_names}\n```" + embed.set_footer(text="To add more pride leaders, feel free to open a pull request!") + + return embed + + def embed_builder(self, pride_leader: dict) -> discord.Embed: + """Generate an Embed with information about a pride leader.""" + name = [name for name, info in PRIDE_RESOURCE.items() if info == pride_leader][0] + + embed = discord.Embed( + title=name, + description=pride_leader["About"], + color=constants.Colours.blue + ) + embed.add_field( + name="Known for", + value=pride_leader["Known for"], + inline=False + ) + embed.add_field( + name="D.O.B and Birth place", + value=pride_leader["Born"], + inline=False + ) + embed.add_field( + name="Awards and honors", + value=pride_leader["Awards"], + inline=False + ) + embed.add_field( + name="For More Information", + value=f"Do `{constants.Client.prefix}wiki {name}`" + f" in <#{constants.Channels.community_bot_commands}>", + inline=False + ) + embed.set_thumbnail(url=pride_leader["url"]) + return embed + + @commands.command(aliases=("pl", "prideleader")) + async def pride_leader(self, ctx: commands.Context, *, pride_leader_name: Optional[str]) -> None: + """ + Information about a Pride Leader. + + Returns information about the specified pride leader + and if there is no pride leader given, return a random pride leader. + """ + if not pride_leader_name: + leader = random.choice(list(PRIDE_RESOURCE.values())) + else: + leader = PRIDE_RESOURCE.get(pride_leader_name.title()) + if not leader: + log.trace(f"Got a Invalid pride leader: {pride_leader_name}") + + embed = self.invalid_embed_generate(pride_leader_name) + await ctx.send(embed=embed) + return + + embed = self.embed_builder(leader) + await ctx.send(embed=embed) + + +def setup(bot: Bot) -> None: + """Load the Pride Leader Cog.""" + bot.add_cog(PrideLeader(bot)) diff --git a/bot/exts/pride/drag_queen_name.py b/bot/exts/pride/drag_queen_name.py deleted file mode 100644 index 15ca6576..00000000 --- a/bot/exts/pride/drag_queen_name.py +++ /dev/null @@ -1,26 +0,0 @@ -import json -import logging -import random -from pathlib import Path - -from discord.ext import commands - -from bot.bot import Bot - -log = logging.getLogger(__name__) - -NAMES = json.loads(Path("bot/resources/pride/drag_queen_names.json").read_text("utf8")) - - -class DragNames(commands.Cog): - """Gives a random drag queen name!""" - - @commands.command(name="dragname", aliases=("dragqueenname", "queenme")) - async def dragname(self, ctx: commands.Context) -> None: - """Sends a message with a drag queen name.""" - await ctx.send(random.choice(NAMES)) - - -def setup(bot: Bot) -> None: - """Load the Drag Names Cog.""" - bot.add_cog(DragNames()) diff --git a/bot/exts/pride/pride_anthem.py b/bot/exts/pride/pride_anthem.py deleted file mode 100644 index 05286b3d..00000000 --- a/bot/exts/pride/pride_anthem.py +++ /dev/null @@ -1,51 +0,0 @@ -import json -import logging -import random -from pathlib import Path -from typing import Optional - -from discord.ext import commands - -from bot.bot import Bot - -log = logging.getLogger(__name__) - -VIDEOS = json.loads(Path("bot/resources/pride/anthems.json").read_text("utf8")) - - -class PrideAnthem(commands.Cog): - """Embed a random youtube video for a gay anthem!""" - - def get_video(self, genre: Optional[str] = None) -> dict: - """ - Picks a random anthem from the list. - - If `genre` is supplied, it will pick from videos attributed with that genre. - If none can be found, it will log this as well as provide that information to the user. - """ - if not genre: - return random.choice(VIDEOS) - else: - songs = [song for song in VIDEOS if genre.casefold() in song["genre"]] - try: - return random.choice(songs) - except IndexError: - log.info("No videos for that genre.") - - @commands.command(name="prideanthem", aliases=("anthem", "pridesong")) - async def prideanthem(self, ctx: commands.Context, genre: str = None) -> None: - """ - Sends a message with a video of a random pride anthem. - - If `genre` is supplied, it will select from that genre only. - """ - anthem = self.get_video(genre) - if anthem: - await ctx.send(anthem["url"]) - else: - await ctx.send("I couldn't find a video, sorry!") - - -def setup(bot: Bot) -> None: - """Load the Pride Anthem Cog.""" - bot.add_cog(PrideAnthem()) diff --git a/bot/exts/pride/pride_facts.py b/bot/exts/pride/pride_facts.py deleted file mode 100644 index 63e33dda..00000000 --- a/bot/exts/pride/pride_facts.py +++ /dev/null @@ -1,99 +0,0 @@ -import json -import logging -import random -from datetime import datetime -from pathlib import Path -from typing import Union - -import dateutil.parser -import discord -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Channels, Colours, Month -from bot.utils.decorators import seasonal_task - -log = logging.getLogger(__name__) - -FACTS = json.loads(Path("bot/resources/pride/facts.json").read_text("utf8")) - - -class PrideFacts(commands.Cog): - """Provides a new fact every day during the Pride season!""" - - def __init__(self, bot: Bot): - self.bot = bot - self.daily_fact_task = self.bot.loop.create_task(self.send_pride_fact_daily()) - - @seasonal_task(Month.JUNE) - async def send_pride_fact_daily(self) -> None: - """Background task to post the daily pride fact every day.""" - await self.bot.wait_until_guild_available() - - channel = self.bot.get_channel(Channels.community_bot_commands) - await self.send_select_fact(channel, datetime.utcnow()) - - async def send_random_fact(self, ctx: commands.Context) -> None: - """Provides a fact from any previous day, or today.""" - now = datetime.utcnow() - previous_years_facts = (y for x, y in FACTS.items() if int(x) < now.year) - current_year_facts = FACTS.get(str(now.year), [])[:now.day] - previous_facts = current_year_facts + [x for y in previous_years_facts for x in y] - try: - await ctx.send(embed=self.make_embed(random.choice(previous_facts))) - except IndexError: - await ctx.send("No facts available") - - async def send_select_fact(self, target: discord.abc.Messageable, _date: Union[str, datetime]) -> None: - """Provides the fact for the specified day, if the day is today, or is in the past.""" - now = datetime.utcnow() - if isinstance(_date, str): - try: - date = dateutil.parser.parse(_date, dayfirst=False, yearfirst=False, fuzzy=True) - except (ValueError, OverflowError) as err: - await target.send(f"Error parsing date: {err}") - return - else: - date = _date - if date.year < now.year or (date.year == now.year and date.day <= now.day): - try: - await target.send(embed=self.make_embed(FACTS[str(date.year)][date.day - 1])) - except KeyError: - await target.send(f"The year {date.year} is not yet supported") - return - except IndexError: - await target.send(f"Day {date.day} of {date.year} is not yet support") - return - else: - await target.send("The fact for the selected day is not yet available.") - - @commands.command(name="pridefact", aliases=("pridefacts",)) - async def pridefact(self, ctx: commands.Context, option: str = None) -> None: - """ - Sends a message with a pride fact of the day. - - If "random" is given as an argument, a random previous fact will be provided. - - If a date is given as an argument, and the date is in the past, the fact from that day - will be provided. - """ - if not option: - await self.send_select_fact(ctx, datetime.utcnow()) - elif option.lower().startswith("rand"): - await self.send_random_fact(ctx) - else: - await self.send_select_fact(ctx, option) - - @staticmethod - def make_embed(fact: str) -> discord.Embed: - """Makes a nice embed for the fact to be sent.""" - return discord.Embed( - colour=Colours.pink, - title="Pride Fact!", - description=fact - ) - - -def setup(bot: Bot) -> None: - """Load the Pride Facts Cog.""" - bot.add_cog(PrideFacts(bot)) diff --git a/bot/exts/pride/pride_leader.py b/bot/exts/pride/pride_leader.py deleted file mode 100644 index 5684ff37..00000000 --- a/bot/exts/pride/pride_leader.py +++ /dev/null @@ -1,117 +0,0 @@ -import json -import logging -import random -from pathlib import Path -from typing import Optional - -import discord -from discord.ext import commands -from rapidfuzz import fuzz - -from bot import constants -from bot.bot import Bot - -log = logging.getLogger(__name__) - -PRIDE_RESOURCE = json.loads(Path("bot/resources/pride/prideleader.json").read_text("utf8")) -MINIMUM_FUZZ_RATIO = 40 - - -class PrideLeader(commands.Cog): - """Gives information about Pride Leaders.""" - - def __init__(self, bot: Bot): - self.bot = bot - - def invalid_embed_generate(self, pride_leader: str) -> discord.Embed: - """ - Generates Invalid Embed. - - The invalid embed contains a list of closely matched names of the invalid pride - leader the user gave. If no closely matched names are found it would list all - the available pride leader names. - - Wikipedia is a useful place to learn about pride leaders and we don't have all - the pride leaders, so the bot would add a field containing the wikipedia - command to execute. - """ - embed = discord.Embed( - color=constants.Colours.soft_red - ) - valid_names = [] - pride_leader = pride_leader.title() - for name in PRIDE_RESOURCE: - if fuzz.ratio(pride_leader, name) >= MINIMUM_FUZZ_RATIO: - valid_names.append(name) - - if not valid_names: - valid_names = ", ".join(PRIDE_RESOURCE) - error_msg = "Sorry your input didn't match any stored names, here is a list of available names:" - else: - valid_names = "\n".join(valid_names) - error_msg = "Did you mean?" - - embed.description = f"{error_msg}\n```\n{valid_names}\n```" - embed.set_footer(text="To add more pride leaders, feel free to open a pull request!") - - return embed - - def embed_builder(self, pride_leader: dict) -> discord.Embed: - """Generate an Embed with information about a pride leader.""" - name = [name for name, info in PRIDE_RESOURCE.items() if info == pride_leader][0] - - embed = discord.Embed( - title=name, - description=pride_leader["About"], - color=constants.Colours.blue - ) - embed.add_field( - name="Known for", - value=pride_leader["Known for"], - inline=False - ) - embed.add_field( - name="D.O.B and Birth place", - value=pride_leader["Born"], - inline=False - ) - embed.add_field( - name="Awards and honors", - value=pride_leader["Awards"], - inline=False - ) - embed.add_field( - name="For More Information", - value=f"Do `{constants.Client.prefix}wiki {name}`" - f" in <#{constants.Channels.community_bot_commands}>", - inline=False - ) - embed.set_thumbnail(url=pride_leader["url"]) - return embed - - @commands.command(aliases=("pl", "prideleader")) - async def pride_leader(self, ctx: commands.Context, *, pride_leader_name: Optional[str]) -> None: - """ - Information about a Pride Leader. - - Returns information about the specified pride leader - and if there is no pride leader given, return a random pride leader. - """ - if not pride_leader_name: - leader = random.choice(list(PRIDE_RESOURCE.values())) - else: - leader = PRIDE_RESOURCE.get(pride_leader_name.title()) - if not leader: - log.trace(f"Got a Invalid pride leader: {pride_leader_name}") - - embed = self.invalid_embed_generate(pride_leader_name) - await ctx.send(embed=embed) - return - - embed = self.embed_builder(leader) - await ctx.send(embed=embed) - - -def setup(bot: Bot) -> None: - """Load the Pride Leader Cog.""" - bot.add_cog(PrideLeader(bot)) diff --git a/bot/resources/holidays/pride/anthems.json b/bot/resources/holidays/pride/anthems.json new file mode 100644 index 00000000..fd8e8b92 --- /dev/null +++ b/bot/resources/holidays/pride/anthems.json @@ -0,0 +1,455 @@ +[ + { + "title": "Bi", + "artist": "Alicia Champion", + "url": "https://www.youtube.com/watch?v=HekhW9STg58", + "genre": [ + "pop" + ] + }, + { + "title": "Queer Kidz", + "artist": "Ashby and the Oceanns", + "url": "https://www.youtube.com/watch?v=tSdCciMIlO8", + "genre": [ + "folk" + ] + }, + { + "title": "I Like Boys", + "artist": "Todrick Hall", + "url": "https://www.youtube.com/watch?v=RIbGksV3YBY", + "genre": [ + "dance", + "electronic" + ] + }, + { + "title": "It Girl", + "artist": "Mister Wallace", + "url": "https://www.youtube.com/watch?v=NEnmporrBuo", + "genre": [ + "dance", + "electronic" + ] + }, + { + "title": "Gay Sex", + "artist": "Be Steadwell", + "url": "https://www.youtube.com/watch?v=XnbQu_pzf8o", + "genre": [ + "pop" + ] + }, + { + "title": "Pynk", + "artist": "Janelle Monae", + "url": "https://www.youtube.com/watch?v=PaYvlVR_BEc", + "genre": [ + "rnb", + "rhythm and blues", + "r&b", + "soul" + ] + }, + { + "title": "I don't do boys", + "artist": "Elektra", + "url": "https://www.youtube.com/watch?v=MxAvsYrHOmI", + "genre": [ + "rock", + "pop" + ] + }, + { + "title": "Girls Like Girls", + "artist": "Hayley Kiyoko", + "url": "https://www.youtube.com/watch?v=I0MT8SwNa_U", + "genre": [ + "pop", + "electropop" + ] + }, + { + "title": "Girls/Girls/Boys", + "artist": "Panic! at the Disco", + "url": "https://www.youtube.com/watch?v=Yk8jV7r6VMk", + "genre": [ + "alt", + "alternative", + "indie", + "new-wave", + "electropop", + "pop", + "rock" + ] + }, + { + "title": "I Will Survive", + "artist": "Gloria Gaynor", + "url": "https://www.youtube.com/watch?v=gYkACVDFmeg", + "genre": [ + "jazz", + "disco", + "rnb", + "r&b", + "rhythm and blues", + "soul", + "dance", + "electronic", + "pop" + ] + }, + { + "title": "Born This Way", + "artist": "Lady Gaga", + "url": "https://www.youtube.com/watch?v=wV1FrqwZyKw", + "genre": [ + "pop", + "electropop" + ] + }, + { + "title": "Raise Your Glass", + "artist": "P!nk", + "url": "https://www.youtube.com/watch?v=XjVNlG5cZyQ", + "genre": [ + "pop", + "rock", + "pop-rock" + ] + }, + { + "title": "We R Who We R", + "artist": "Ke$ha", + "url": "https://www.youtube.com/watch?v=mXvmSaE0JXA", + "genre": [ + "pop", + "dance-pop" + ] + }, + { + "title": "I'm Coming Out", + "artist": "Diana Ross", + "url": "https://www.youtube.com/watch?v=zbYcte4ZEgQ", + "genre": [ + "disco", + "funk", + "soul" + ] + }, + { + "title": "She Keeps Me Warm", + "artist": "Mary Lambert", + "url": "https://www.youtube.com/watch?v=NhqH-r7Xj0E", + "genre": [ + "pop" + ] + }, + { + "title": "June", + "artist": "Florence + The Machine", + "url": "https://www.youtube.com/watch?v=Sosmd6RjeA0", + "genre": [ + "alt", + "indie", + "alternative" + ] + }, + { + "title": "Do I Wanna Know", + "artist": "MS MR", + "url": "https://youtu.be/0DCDf1O4e1Q", + "genre": [ + "indie", + "indie-pop" + ] + }, + { + "title": "Delilah", + "artist": "Florence + The Machine", + "url": "https://www.youtube.com/watch?v=zZr5Tid3Qw4", + "genre": [ + "alt", + "alternative", + "indie" + ] + }, + { + "title": "Queen", + "artist": "Janelle Monae", + "url": "https://www.youtube.com/watch?v=tEddixS-UoU", + "genre": [ + "neo-soul" + ] + }, + { + "title": "Aesthetic", + "artist": "Hi, I'm Case", + "url": "https://www.youtube.com/watch?v=cgq-XaSC1aY", + "genre": [ + "pop", + "pop-rock" + ] + }, + { + "title": "Break Free", + "artist": "Queen", + "url": "https://www.youtube.com/watch?v=f4Mc-NYPHaQ", + "genre": [ + "rock", + "synth-pop" + ] + }, + { + "title": "LGBT", + "artist": "CupcakKe", + "url": "https://www.youtube.com/watch?v=U_OArkw5yeQ", + "genre": [ + "hip-hop", + "rap" + ] + }, + { + "title": "Rainbow Connections", + "artist": "Garfunkel and Oates", + "url": "https://www.youtube.com/watch?v=MneRtx7x2vs", + "genre": [ + "folk" + ] + }, + { + "title": "Proud", + "artist": "Heather Small", + "url": "https://www.youtube.com/watch?v=LEoxGJ79PMs", + "genre": [ + "dance-pop", + "r&b", + "rhythm and blues", + "rnb" + ] + }, + { + "title": "LGBT", + "artist": "Lowell", + "url": "https://www.youtube.com/watch?v=QgE6nZmTGLw", + "genre": [ + "alternative", + "indie", + "alt", + "pop" + ] + }, + { + "title": "Kiss the Boy", + "artist": "Keiynan Lonsdale", + "url": "https://www.youtube.com/watch?v=bXzLZ7QQnpQ", + "genre": [ + "pop" + ] + }, + { + "title": "Boys Aside", + "artist": "Sofya Wang", + "url": "https://www.youtube.com/watch?v=NlAW7l6dmeA", + "genre": [ + "pop" + ] + }, + { + "title": "Everyone is Gay", + "artist": "A Great Big World", + "url": "https://www.youtube.com/watch?v=0VG1bj4Lj1Q", + "genre": [ + "pop" + ] + }, + { + "title": "The Queer Gospel", + "artist": "Erin McKeown", + "url": "https://www.youtube.com/watch?v=2vvOEoT-q_o", + "genre": [ + "christian", + "gospel" + ] + }, + { + "title": "Girls", + "artist": "Girl in Red", + "url": "https://www.youtube.com/watch?v=_BMBDY01kPk", + "genre": [ + "alternative", + "indie", + "alt" + ] + }, + { + "title": "Crazy World", + "artist": "MNEK", + "url": "https://www.youtube.com/watch?v=YBwzTgNL-zw", + "genre": [ + "pop" + ] + }, + { + "title": "Pride", + "artist": "Grace Petrie", + "url": "https://www.youtube.com/watch?v=y5rMrPJzFGs", + "genre": [ + "alt", + "alternative", + "indie" + ] + }, + { + "title": "Good Guys", + "artist": "MIKA", + "url": "https://www.youtube.com/watch?v=VZQ_9eebry0", + "genre": [ + "pop" + ] + }, + { + "title": "Gender is Boring", + "artist": "She/Her/Hers", + "url": "https://www.youtube.com/watch?v=glJW2vlBAQg", + "genre": [ + "punk" + ] + }, + { + "title": "GUY.exe", + "artist": "Superfruit", + "url": "https://www.youtube.com/watch?v=r2Kh_XMIDPU", + "genre": [ + "pop" + ] + }, + { + "title": "That's So Gay", + "artist": "Pansy Division", + "url": "https://www.youtube.com/watch?v=xlpcyeadaTk", + "genre": [ + "rock" + ] + }, + { + "title": "Strangers", + "artist": "Halsey", + "url": "https://www.youtube.com/watch?v=RVd_71ZdRd4", + "genre": [ + "pop", + "alt", + "alternative", + "indie", + "electropop" + ] + }, + { + "title": "LGBTQIA", + "artist": "Matt Fishel", + "url": "https://www.youtube.com/watch?v=KQq9f5hNOxE", + "genre": [ + "rock" + ] + }, + { + "title": "Tell Me a Story", + "artist": "Skylar Kergil", + "url": "https://www.youtube.com/watch?v=nbQDTE2s3dI", + "genre": [ + "folk" + ] + }, + { + "title": "Trans Is Love", + "artist": "Marissa Kay", + "url": "https://www.youtube.com/watch?v=-5f_1H0RD1I", + "genre": [ + "alt", + "alternative", + "indie", + "alt-country", + "alt-folk", + "indie-rock", + "new-southern-rock" + ] + }, + { + "title": "You Can't Tell Me", + "artist": "Jake Edwards", + "url": "https://www.youtube.com/watch?v=CwqDG5269Ak", + "genre": [ + "pop" + ] + }, + { + "title": "Closet Femme", + "artist": "Kate Reid", + "url": "https://www.youtube.com/watch?v=va-nqcNxP_k", + "genre": [ + "folk" + ] + }, + { + "title": "Let's Have a Kiki", + "artist": "Scissor Sisters", + "url": "https://www.youtube.com/watch?v=eGCD4xb-Tr8", + "genre": [ + "electropop", + "pop" + ] + }, + { + "title": "Gimme Gimme Gimme", + "artist": "ABBA", + "url": "https://www.youtube.com/watch?v=JWay7CDEyAI", + "genre": [ + "disco" + ] + }, + { + "title": "Dancing Queen", + "artist": "ABBA", + "url": "https://www.youtube.com/watch?v=xFrGuyw1V8s", + "genre": [ + "disco", + "europop", + "euero-disco" + ] + }, + { + "title": "City Grrl", + "artist": "CSS", + "url": "https://www.youtube.com/watch?v=duOA3FgpZqY", + "genre": [ + "electropop" + ] + }, + { + "title": "Blame it on the Girls", + "artist": "MIKA", + "url": "https://www.youtube.com/watch?v=iF_w7oaBHNo", + "genre": [ + "pop", + "pop-rock" + ] + }, + { + "title": "Bye Bye Bye", + "artist": "*NSYNC", + "url": "https://www.youtube.com/watch?v=Eo-KmOd3i7s", + "genre": [ + "pop", + "europop" + ] + }, + { + "title": "Gettin Bi", + "artist": "Crazy Ex-Girlfriend", + "url": "https://www.youtube.com/watch?v=5e7844P77Is", + "genre": [ + "pop" + ] + } +] diff --git a/bot/resources/holidays/pride/drag_queen_names.json b/bot/resources/holidays/pride/drag_queen_names.json new file mode 100644 index 00000000..023dff36 --- /dev/null +++ b/bot/resources/holidays/pride/drag_queen_names.json @@ -0,0 +1,249 @@ +[ + "Adelle Lectible", + "Adelle Light", + "Adelle Lirious", + "Alison Wonder", + "Amie Thyst", + "Amie Zonite", + "Angela Develle", + "Anna Conda", + "Anne Amaley", + "Annie Nigma", + "Aria Hymn", + "Aria Viderci", + "Aroa Mattic", + "Aster Starr", + "Aura Aurora", + "Aura Ley", + "Aurora Dorea", + "Barba Rouse", + "Bea Constrictor", + "Bella Lush", + "Belle Icoza", + "Belle Ligerrente", + "Betty Brilliance", + "Bo Deysious", + "Carol Chorale", + "Cecil Clouds", + "Cecil Sunshine", + "Celeste Booday", + "Chichi Swank", + "Claire Geeman", + "Claire Rickal", + "Claire Voyance", + "Cleo Patrix", + "Connie Fidence", + "Corra Rageous", + "Daye Light", + "Deedee Cation", + "Deedee Sign", + "Dianne Gerous", + "Didi Divine", + "Diemme Monds", + "Dorothy Doughty", + "Dutches Dauntless", + "Ella Gance", + "Ella Gants", + "Ella Menterry", + "Ella Stique", + "Elle Lectrick", + "Elle Lure", + "Emma Geddon", + "Emma Phasis", + "Emma Rald", + "Emme Plosion", + "Emme Pulse", + "Emme Vention", + "Enna Fincible", + "Enne Phinite", + "Enne Treppide", + "Etha Nitty", + "Etha Reyal", + "Euphoria Bliss", + "Eva Nessent", + "Eve Forric", + "Eve Ningowne", + "Eve Ville", + "Faith Lesse", + "Faschia Nation", + "Faye Boulous", + "Faye Lacious", + "Faye Minine", + "Faye Nixx", + "Felicity Spice", + "Freya Domme", + "Gal Gallant", + "Gal Galore", + "Gal Lante", + "Gemma Safir", + "Gena Rocity", + "Genna Russ", + "Gigi Lamour", + "Gigi Rand", + "Glemma Rouss", + "Grace Iyus", + "Haye Light", + "Hazel Nutt", + "Hella Billy", + "Hella Centrique", + "Hella Cious", + "Hella Riouss", + "Hella Whole", + "Hellen Back", + "Herra Zee", + "Ina Creddeble", + "Ina Fernalle", + "Jo Nee", + "Jo Phial", + "Joye Ryde", + "Jue Cee", + "Jue Wells", + "Juju Bee", + "Kaia Cayenne", + "Kaye Bye", + "Kitsch Kitsch Bang Bang", + "Lady Lace", + "Lavish Lazuli", + "Lea Ness", + "Leye Berty", + "Lisse Truss", + "Liv Lee", + "Lola Lavish", + "Lolo Yaltie", + "Lucy Fur", + "Lucy Luck", + "Lulu LaBye", + "Lulu Xuri", + "Lunaye Clipse", + "Lyra Kall", + "Maggie Magma", + "Mara Bells", + "Marry Golds", + "Marry Nayde", + "Marry Sipan", + "Marve Vellus", + "Mary Ganal", + "Mary Malade", + "May Jestic", + "May Lancholly", + "May Licious", + "May Lodi", + "May Morable", + "May Stirius", + "May Varlous", + "Melody Gale", + "Melody Toune", + "Miss Adora", + "Miss Alure", + "Miss Chieff", + "Miss Fortune", + "Miss Mash", + "Miss Mood", + "Miss Nomer", + "Miss Sanguine", + "Miss Sublime", + "Mistress Galore", + "Monique Mystique", + "Morgan Fatana", + "Nashay Kitt", + "Nicole Lorful", + "Noë Stalgia", + "Ora Kelle", + "Ora Nate", + "Patty Siyens", + "Penny Laized", + "Penny Ramma", + "Penny Rammic", + "Penny Talloons", + "Percey Ferance", + "Perry Fomance", + "Phara Waye", + "Phata Morgana", + "Pho Latyle", + "Pho Lume", + "Phoebe Rant", + "Phoenix Bright", + "Pippa Pepper", + "Pippa Pizazz", + "Polly Tickle", + "Poppy Corn", + "Poppy Cox", + "Poppy Domm", + "Poppy Larr", + "Poppy Lerry", + "Poppy Sickles", + "Portia Bella", + "Portia Nette", + "Pria Steegious", + "Pria Steen", + "Prissa Teen", + "Raye Bitt", + "Raye Diante", + "Raye Nessance", + "Raye Storm", + "Remi Nissent", + "Rey Mantique", + "Rey Markeble", + "Rey Moorse", + "Rey Torric", + "Rococo Jazz", + "Roma Ence", + "Rose Budd", + "Ruby Redd", + "Ruby Ree", + "Ruth Lezz", + "Sall Laikeen", + "Sall Lay", + "Sally Ness", + "Sam Armie", + "Sam Ooth", + "Sara Castique", + "Sara Donique", + "Sara Penth", + "Sarah Pentine", + "Sarah Reen", + "Sasha Sass", + "Satty Phection", + "Sella Fish", + "Sella Stice", + "Selly Foxx", + "Senna Guinne", + "Senna Seer", + "Shia Mirring", + "Sia Dellic", + "Sia Dowe", + "Siam Pathy", + "Silver Foxx", + "Siri Price", + "Sofie Moore", + "Sofie Stication", + "Su Blime", + "Sue Burben", + "Sue Missif", + "Sue Pernova", + "Sue Preem", + "Super Nova", + "Suse Pense", + "Suzu Blime", + "Temma Tation", + "Tempest Wilde", + "Terra Gique", + "Thea Terre", + "Tina Cious", + "Tina Scious", + "Tira Mendus", + "Tira Quoise", + "Trinity Quart", + "Trixie Foxx", + "Tye Gress", + "Tye Phun", + "Vall Canno", + "Vall Iant", + "Vall Orous", + "Vanity Fairchild", + "Vicki Tory", + "Vivi Venus", + "Vivian Foxx", + "Vye Vacius", + "Zahara Dessert" +] diff --git a/bot/resources/holidays/pride/facts.json b/bot/resources/holidays/pride/facts.json new file mode 100644 index 00000000..2151f5ca --- /dev/null +++ b/bot/resources/holidays/pride/facts.json @@ -0,0 +1,34 @@ +{ + "2020": [ + "No research has conclusively proven what causes homosexuality, heterosexuality, or bisexuality.", + "Records of same-sex relationships have been found in nearly every culture throughout history with varying degrees of acceptance.", + "Various slurs targeting queer people have been reappropriated by them, notably \"dyke\", and \"queer\".", + "Historians note that in some cultures, some homosexual behavior was not viewed as effeminate, but as evidence of a man's masculinity. Examples include the Celtic and Greek cultures.", + "Over time, the proportion of people who identify as homosexual or bisexual appears to be increasing. It is not know if this is because they feel it is safer to come out, or if the actual numbers of homosexual/bisexual people are increasing.", + "A large proportion of people, both in and out of the LGBTQ+ communities, do not believe bisexuality exists. This is known as bisexual erasure.", + "Queer people commit suicide are much more common in politically conservative regions, and also more common than non-queer people in general.", + "October 8th is lesbian pride day!", + "Stormé DeLarverie, a lesbian drag king, had a \"scuffle\" with the police which many claim is what kicked off the Stonewall Riots.", + "Gilbert Baker, also known as the “Gay Betsy Ross,” designed the rainbow flag, or Pride Flag, in San Francisco in 1978.", + "The rainbow pride flag is well-known, but there are flags for most labeled gender/sexual minorities.", + "In 1968, Dr. John Money performed the first complete male-to-female sex-change operation in the United States at Johns Hopkins University.", + "At the age of 24, Leonardo Da Vinci was arrested for having sex with a man. He was eventually acquitted.", + "Alfred Kinsey, the creator of the Kinsey scale, is known as \"the father of the sexual revolution\". The Kinsey scale was created in order to demonstrate that sexuality does not fit into two strict categories: homosexual and heterosexual. Instead, Kinsey believed that sexuality is fluid and subject to change over time.", + "The Kinsey scale ranges from 0, which is exclusively heterosexual, to 6, which is exclusively homosexual.", + "November 20th is the Transgender Day of Remembrance, which is a day to memorialize those who have been murdered as a result of transphobia.", + "The pink triangle was the symbol that queer people were required to wear in Nazi concentration camps during WWII. The symbol has since been reclaimed as a positive symbol of self-identity.", + "The term \"AC/DC\" has been used to refer to bisexuals.", + "September 23rd is bisexual pride day!", + "Pride Day refers to celebrations that typically take place in June that commemorate the Stonewall Inn riots of June 28, 1969. These riots are considered the birth of the modern gay civil rights movement.", + "A \"beard\" is someone of the opposite sex who knowingly dates a closeted lesbian or gay man to provide that person with a heterosexual \"disguise\", usually for family or career purposes.", + "In Nigeria, where homosexuality is punishable by death by stoning, a post-grad student claimed he had proved being gay was wrong by using magnets. He hoped to win a Nobel Prize for his research. He has not received one.", + "In 1982, the Gay Related Immune Disorder (GRID) was renamed Acquired Immune Deficiency Syndrome (AIDS).", + "The word \"lesbian\" is derived from the Greek island Lesbos, home of Greek poet Sappho. Her poetry proclaimed her love for women, and their beauty.", + "Nearly all bonobos (a kind of chimpanzee) appear to be bisexual.", + "Homosexual behavior has been observed in 1,500 animal species and is most widespread among animals with a complex herd life.", + "Many queer people identify their sexual orientation independently from their romantic orientation. For instance, it is possible to be sexually attracted to both women and men, but only be romantically attracted to one of them.", + "In 2005, Swedish researchers found that when straight men smelled a female urine compound, their hypothalamus lit up in brain images. In gay men, it did not. Instead, homosexual men's hypothalamus lit up when they smelled the male-sweat compound, which was the same way straight women responded.", + "As of 2019-10-02, there are 17 states in the United States of America where queer people can be fired for being queer. In most other states, there is minimal protection offered, often only for public employees.", + "In 1985, an official Star Trek novel was published with scenes depicting Kirk and Spock as lovers. These parts were largely removed, which made the original into a collector's item." + ] +} diff --git a/bot/resources/holidays/pride/flags/agender.png b/bot/resources/holidays/pride/flags/agender.png new file mode 100644 index 00000000..8a09e5fa Binary files /dev/null and b/bot/resources/holidays/pride/flags/agender.png differ diff --git a/bot/resources/holidays/pride/flags/androgyne.png b/bot/resources/holidays/pride/flags/androgyne.png new file mode 100644 index 00000000..da40ec01 Binary files /dev/null and b/bot/resources/holidays/pride/flags/androgyne.png differ diff --git a/bot/resources/holidays/pride/flags/aromantic.png b/bot/resources/holidays/pride/flags/aromantic.png new file mode 100644 index 00000000..7c42a200 Binary files /dev/null and b/bot/resources/holidays/pride/flags/aromantic.png differ diff --git a/bot/resources/holidays/pride/flags/asexual.png b/bot/resources/holidays/pride/flags/asexual.png new file mode 100644 index 00000000..c339b239 Binary files /dev/null and b/bot/resources/holidays/pride/flags/asexual.png differ diff --git a/bot/resources/holidays/pride/flags/bigender.png b/bot/resources/holidays/pride/flags/bigender.png new file mode 100644 index 00000000..9864f9bb Binary files /dev/null and b/bot/resources/holidays/pride/flags/bigender.png differ diff --git a/bot/resources/holidays/pride/flags/bisexual.png b/bot/resources/holidays/pride/flags/bisexual.png new file mode 100644 index 00000000..2479bc8e Binary files /dev/null and b/bot/resources/holidays/pride/flags/bisexual.png differ diff --git a/bot/resources/holidays/pride/flags/demiboy.png b/bot/resources/holidays/pride/flags/demiboy.png new file mode 100644 index 00000000..95f68717 Binary files /dev/null and b/bot/resources/holidays/pride/flags/demiboy.png differ diff --git a/bot/resources/holidays/pride/flags/demigirl.png b/bot/resources/holidays/pride/flags/demigirl.png new file mode 100644 index 00000000..6df49bce Binary files /dev/null and b/bot/resources/holidays/pride/flags/demigirl.png differ diff --git a/bot/resources/holidays/pride/flags/demisexual.png b/bot/resources/holidays/pride/flags/demisexual.png new file mode 100644 index 00000000..5339330e Binary files /dev/null and b/bot/resources/holidays/pride/flags/demisexual.png differ diff --git a/bot/resources/holidays/pride/flags/gay.png b/bot/resources/holidays/pride/flags/gay.png new file mode 100644 index 00000000..5a454ca3 Binary files /dev/null and b/bot/resources/holidays/pride/flags/gay.png differ diff --git a/bot/resources/holidays/pride/flags/genderfluid.png b/bot/resources/holidays/pride/flags/genderfluid.png new file mode 100644 index 00000000..ac22f093 Binary files /dev/null and b/bot/resources/holidays/pride/flags/genderfluid.png differ diff --git a/bot/resources/holidays/pride/flags/genderqueer.png b/bot/resources/holidays/pride/flags/genderqueer.png new file mode 100644 index 00000000..4652c7e6 Binary files /dev/null and b/bot/resources/holidays/pride/flags/genderqueer.png differ diff --git a/bot/resources/holidays/pride/flags/intersex.png b/bot/resources/holidays/pride/flags/intersex.png new file mode 100644 index 00000000..c58a3bfe Binary files /dev/null and b/bot/resources/holidays/pride/flags/intersex.png differ diff --git a/bot/resources/holidays/pride/flags/lesbian.png b/bot/resources/holidays/pride/flags/lesbian.png new file mode 100644 index 00000000..824b9a89 Binary files /dev/null and b/bot/resources/holidays/pride/flags/lesbian.png differ diff --git a/bot/resources/holidays/pride/flags/nonbinary.png b/bot/resources/holidays/pride/flags/nonbinary.png new file mode 100644 index 00000000..ee3c50e2 Binary files /dev/null and b/bot/resources/holidays/pride/flags/nonbinary.png differ diff --git a/bot/resources/holidays/pride/flags/omnisexual.png b/bot/resources/holidays/pride/flags/omnisexual.png new file mode 100644 index 00000000..2527051d Binary files /dev/null and b/bot/resources/holidays/pride/flags/omnisexual.png differ diff --git a/bot/resources/holidays/pride/flags/pangender.png b/bot/resources/holidays/pride/flags/pangender.png new file mode 100644 index 00000000..38004654 Binary files /dev/null and b/bot/resources/holidays/pride/flags/pangender.png differ diff --git a/bot/resources/holidays/pride/flags/pansexual.png b/bot/resources/holidays/pride/flags/pansexual.png new file mode 100644 index 00000000..0e56b534 Binary files /dev/null and b/bot/resources/holidays/pride/flags/pansexual.png differ diff --git a/bot/resources/holidays/pride/flags/polyamory.png b/bot/resources/holidays/pride/flags/polyamory.png new file mode 100644 index 00000000..b41f061f Binary files /dev/null and b/bot/resources/holidays/pride/flags/polyamory.png differ diff --git a/bot/resources/holidays/pride/flags/polysexual.png b/bot/resources/holidays/pride/flags/polysexual.png new file mode 100644 index 00000000..b2aba22c Binary files /dev/null and b/bot/resources/holidays/pride/flags/polysexual.png differ diff --git a/bot/resources/holidays/pride/flags/transgender.png b/bot/resources/holidays/pride/flags/transgender.png new file mode 100644 index 00000000..73f01043 Binary files /dev/null and b/bot/resources/holidays/pride/flags/transgender.png differ diff --git a/bot/resources/holidays/pride/flags/trigender.png b/bot/resources/holidays/pride/flags/trigender.png new file mode 100644 index 00000000..06ff0f7c Binary files /dev/null and b/bot/resources/holidays/pride/flags/trigender.png differ diff --git a/bot/resources/holidays/pride/gender_options.json b/bot/resources/holidays/pride/gender_options.json new file mode 100644 index 00000000..062742fb --- /dev/null +++ b/bot/resources/holidays/pride/gender_options.json @@ -0,0 +1,41 @@ +{ + "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" +} diff --git a/bot/resources/holidays/pride/prideleader.json b/bot/resources/holidays/pride/prideleader.json new file mode 100644 index 00000000..30e84bdc --- /dev/null +++ b/bot/resources/holidays/pride/prideleader.json @@ -0,0 +1,100 @@ +{ + "Tim Cook": { + "Known for": "CEO of Apple", + "About": "**Timothy Donald Cook** popularly known as Tim Cook. Despite being a notably private person, Tim Cook became the first CEO of a 500 fortune company, coming out as gay in 2014. He revealed his sexual orientation through an open letter published in Bloomberg BusinessWeek. He strongly believes that business leaders need to do their part to make the world a better place. An excerpt from his open letter is as follows: “Part of social progress is understanding that a person is not defined only by one's sexuality, race, or gender.", + "url": "https://image.cnbcfm.com/api/v1/image/105608434-1543945658496rts28qzc.jpg?v=1554921416&w=1400&h=950", + "Born": "In November 1, 1960 at Mobile, Alabama, U.S.", + "Awards": "• Financial Times Person of the Year (2014)\n• Ripple of Change Award (2015)\n• Fortune Magazine's: World's Greatest Leader. (2015)\n• Alabama Academy of Honor: Inductee. (2015)\n• Human Rights Campaign's Visibility Award (2015)\n• Honorary Doctor of Science from University of Glasgow in Glasgow, Scotland (2017)\n• Courage Against Hate award from Anti-Defamation League (2018)" + }, + "Alan Joyce": { + "Known for": "CEO of Qantas Airlines", + "About": "**Alan Joseph Joyce, AC** popularly known as Alan Joyce. Alan Joyce has been named as one of the world’s most influential business executives. He has always been very open about his orientation and has been in a committed gay relationship for over 20 years now. He supports same-sex marriage and believes that it is critical to make people recognize that they know LGBT people\n\nAlan likes to frequently talk about equality at the workplace and said, “It has become more important for leaders who are LGBT to be open about their sexuality. I am passionate about it. There should be more people like Apple’s Tim Cook and Paul Zahra, the former CEO of Australia’s David Jones [store chain].”", + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/83/Alan_Joyce_%28cropped%29.jpg/220px-Alan_Joyce_%28cropped%29.jpg", + "Born": "On 30 June, 1966 at Tallaght, Dublin, Republic of Ireland", + "Awards": "• The Australian named Joyce the most influential business leader in 2011\n• Joyce is an Ambassador of the Australian Indigenous Education Foundation\n• Joyce named a Companion of the Order of Australia, Australia's highest civil honour, in the 2017 Queen's birthday honours list" + }, + "Peter Thiel": { + "Known for": "Co-Founder and Former CEO of paypal", + "About": "**Peter Andreas Thiel** popularly known as Peter Thiel. Peter Thiel served as the CEO of PayPal from its inception to its sale to eBay in October 2002. Thiel’s sexuality came out in 2007 when Gawker Media outed him in a blog post. He became the first openly gay speaker at Republican National Convention and delivered a speech on sexuality.\n\n“Of course every American has a unique identity,” he said. “I am proud to be gay. I am proud to be a Republican. But most of all I am proud to be an American.”", + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3f/Peter_Thiel_2014_by_Heisenberg_Media.jpg/220px-Peter_Thiel_2014_by_Heisenberg_Media.jpg", + "Born": "On 11 October, 1967 at Frankfurt, West Germany", + "Awards": "• Thiel received a co-producer credit for Thank You for Smoking, a 2005 feature film based on Christopher Buckley's 1994 novel of the same name\n• In 2006, Thiel won the Herman Lay Award for Entrepreneurship\n• In 2007, he was honored as a Young Global leader by the World Economic Forum as one of the 250 most distinguished leaders age 40 and under\n• On 7 November 2009, Thiel was awarded an honorary degree from Universidad Francisco Marroquin\n• In 2012, Students For Liberty, an organization dedicated to spreading libertarian ideals on college campuses, awarded Thiel its “Alumnus of the Year” award\n• In February 2013, Thiel received a TechCrunch Crunchie Award for Venture Capitalist of the Year.\n• Insurance Executive of the Year: St Joseph’s University’s Academy of Risk Management and Insurance in Philadelphia" + }, + "Martine Rothblatt": { + "Known for": "CEO of United Therapeutics", + "About": "**Martine Aliana Rothblatt** popularly known as Martine Rothblatt. Martine co-founded Sirius XM Satellite Radio in 1990 and 1996 founded United Therapeutics, making her the highest-earning CEO in the biopharmaceutical industry. She came out as a transgender in 1994 and has been vocal about the trans community ever since.\n\nIn 2014 in an interview, Martine said, “I took a journey from male to female, so if I hide that, I’m, like just replicating the closet of my past with another closet of the future. That made no sense and that is why I am open.” She has authored a book called “The apartheid of Sex”.", + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/bd/Martine-Rothblatt2-5773.jpg/220px-Martine-Rothblatt2-5773.jpg", + "Born": "On October 10, 1954 at Chicago, Illinois, U.S.", + "Awards": "• In January 2018 Rothblatt was presented the UCLA Medal, the university's highest award, in recognition of her creation of Sirius XM satellite radio, advancing organ transplant technology, and having “expanded the way we understand fundamental concepts ranging from communication to gender to the nature of consciousness and mortality.”" + }, + "Peter Arvai": { + "Known for": "Co-Founder and CEO of Prezi", + "About": "Peter Arvai is the 11th most influential LGBT leader in the world. His goal has always been to create an open environment that fosters diversity. He has co-founded an NGO with Google and espell called ‘We Are Open’ to promote openness at the workplace. Peter regularly talks about his gay background in every on-boarding session at his company.\n\nWhen Prezi was featured on the cover of Forbes, Peter used the opportunity by coming out and sharing his story to start a conversation on this topic that most people seem to avoid. If you want to create a more inclusive workplace, you need to be willing to be vulnerable yourself, he says. “To spark honest discussions about inclusivity and openness, your personal experience of inclusion is a key resource and you need to create a safe environment so people find the courage to have uncomfortable conversations.”", + "url": "https://cached.imagescaler.hbpl.co.uk/resize/scaleWidth/880/cached.offlinehbpl.hbpl.co.uk/news/OTM/Peterarvai-20191218101617863.jpg", + "Born": "On October 26, 1979 at Karlskoga, Sweden", + "Awards": "• 2014: European Tech Startups Award for Best Startup Co-Founders.\n• 2014: European Web Entrepreneur of the Year.\n• 2015: Executive of the Year – Business Services: Bronze Stevie Winner.\n• 2016: Number 11 on the 2016 OUTstanding & Financial Times Leading LGBT Executives List of 100" + }, + "Inga Beale": { + "Known for": "CEO of Lloyd's of London", + "About": "Inga became the first female CEO of Lloyd’s of London in 2013 and in 2017 was named a Dame Commander of the Order of the British Empire for her contribution towards the British economy.\n\nShe came out first as a bisexual, in an interview in 2008 and since then has made efforts to bring diversity and inclusion at the forefront in her company. “It’s not about me. It’s about what you do for other people. For me, it’s so important because you need these role models.”\n\nSpeaking between meetings at the World Economic Forum in Davos, she says her position at the top of the LGBT table is important for its impact on others: “It’s about giving people confidence,” she says.", + "url": "https://cdn-res.keymedia.com/cms/images/us/018/0248_637072371134927741.jpeg", + "Born": "On May 15, 1963 at Great Britain", + "Awards": "• Trailblazer of the Year: The Insurance Industry Charitable Foundation (IICF)(2019)\n• Free Enterprise Award: Insurance Federation of New York (IFNY)(2018)\n• Market People - Outstanding Contribution Award: The London Market Forums(2018)\n• Outstanding Achievement Award: Insurance Day | Informa(2018)\n• Barclay's Lifetime Achievement Award: Variety Children's Charity - Catherine Awards(2017)\n• Insurance Woman of the Year Award: Association of Professional Insurance Women (APIW)(2017)\n• Dame Commander of the Order of the British Empire - DBE: HM The Queen(2017)\n• Insurance Personality of the Year: British Insurance Awards\n• Insurance Executive of the Year: St Joseph’s University’s Academy of Risk Management and Insurance in Philadelphia(2015)" + }, + "David Geffen": { + "Known for": "Founder of Dreamworks", + "About": "**David Lawrence Geffen** popularly known as David Geffen. Founder of film studio Dream Works as well as record labels Asylum Records, Geffen Records and DGC Records, David Geffen came out in 1992 at a fund raiser announcing, “As a Gay man, I have come a long way to be here tonight.” He was already among the strongest pillars of the gay rights movement by then.\n\n“If I am going to be a role model, I want to be one that I can be proud of,” Geffen said in an interview back in 1992.”\n\nGeffen contributed significantly towards society through the David Geffen Foundation that worked relentlessly towards healthcare, people affected by HIV/AIDS, civil liberties, issues of concern to the Jewish community, and arts. Interestingly, the song ‘Free man in Paris’ by Joni Mitchell is based on Geffen’s time in Paris during a trip they took together along with Canadian musician Robbie Robertson and his wife.", + "url": "https://i.insider.com/5b733a2be199f31d138b4bec?width=1100&format=jpeg&auto=webp", + "Born": "On February 21, 1943 at New York City, U.S.", + "Awards": "• Tony Award for Best Musical(1983)\n• Tony Award for Best Play(1988)\n• Daytime Emmy Award for Outstanding Children's Animated Program(1990)" + }, + "Joel Simkhai": { + "Known for": "Founder and former CEO of Grindr and Blendr", + "About": "Joel Simkhai founded Grindr, a dating app for men in the LGBTQ+ community in 2009. He says he launched the app with a “selfish desire’ to meet more gay men. Today, Grindr has over 4 million users and has become the world's largest social networking platform for men from the LGBTQ+ community to interact.\n\nIn an interview Joel shared, “ As a kid I was teased, teased for being feminine, I guess for being gay. So a lot of my early life was just in denial about it with myself and others. But by 16 and as I started getting older, I realized that I like guys”. “My story is the story of every gay man. We are seen as a minority in some ways and the services out there weren’t that great. I am an introvert so I don’t really do well at being who I am right away but online seemed like my comfort zone”. It all begins with meeting someone, he adds.", + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/44/Joel_Simkhai_2012_%28cropped%29.jpg/220px-Joel_Simkhai_2012_%28cropped%29.jpg", + "Born": "On 1976 at Tel Aviv, Israel", + "Awards": "• Simkhai’s company has been mentioned in pop culture icons like The Office, Saturday Night Live, Judge Judy and Top Gear and won a number of industry awards including “Best Mobile Dating App” in 2011 and 2012 and “Best New Technology” in 2012 from the iDate Awards and “Best Location Application” at the TechCrunch Awards in 2011." + }, + "Megan Smith": { + "Known for": "Former CTO of United States", + "About": "Megan Smith the former CTO of the United States has always been vocal about the need to push inclusion. Most central to her message, however, is the key insight that is most often lost: not only is inclusivity a part of technology’s future, it was also a seminal part of its past. Ada Lovelace, for example, an English woman born in 1812, was the first computer programmer; Katherine G. Johnson, an African-American woman featured in the Oscar-nominated film Hidden Figures, helped put NASA astronauts on the moon.\n\nIn 2003, in an interview Megan said, “When you are gay, you come out everyday because everyone assumes you are straight. But you have to be yourself.” Smith also hopes to open up the tech industry to more women and encourage girls to pursue a career in it.", + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f0/Megan_Smith_official_portrait.jpg/220px-Megan_Smith_official_portrait.jpg", + "Born": "On October 21, 1964 at Buffalo, New York, and Fort Erie, Ontario.", + "Awards": "• World Economic Forum Technology Pioneer 2001, 2002\n• Listed by Out magazine in 2012 and 2013, as one of the 50 most powerful LGBT people in the United States\n• Reuters Digital Vision Program Fellow at Stanford, 2003-2004\n• Top 25 Women on the Web, 2000\n• Upside Magazine 100 Digital Elite, 1999 and 2000\n• Advertising Age i.20, 1999\n• GLAAD Interactive Media Award for Internet Leadership, 1999\n• Charging Buffalo Award, 2015\n• Business Insider 23 Most Powerful LGBTQ+ People in Tech, 2019" + }, + "David Bohnett": { + "Known for": "Founder of Geocities", + "About": "**David C. Bohnett** popularly known as David Bohnett. A tech entrepreneur, with his LA-based venture firm Baroda Ventures, founded in 1998, David Bohnett is also the Founder of Geocities, which remains the first largest internet venture built on user-generated content, founded in 1998 and acquired by Yahoo in 1999. Geocities was cited #2 by TechRadar in its list of ‘20 websites that changed the world’ back in 2008.\n\nBohnett came out to his family post his graduation and worked extensively towards equal rights for gays and lesbians, and towards legalizing same-sex marriage. He founded the David Bohnett Foundation, dedicated to community-building and social activism addressing concerns across a broad spectrum of arts, educational, and civic programs. The first openly bisexual US congressperson Krysten Sinema and the first openly gay mayor of a major US city (Houston), Annise Parker, are both alumnis of the LGBT Leadership Fellows run by his foundation that trains LGBT leaders for local and state governments.", + "url": "https://upload.wikimedia.org/wikipedia/commons/c/cb/David_Bohnett.jpg", + "Born": "On April 2, 1956 at Chicago, Illinois", + "Awards": "• Number 16 on Time's Top 50 Cyber Elite (1998)\n• Upside magazine's Elite 100 (1998)\n• Newsweek's “100 People to Watch in the Next Millennium”\n• Ernst & Young Entrepreneur of the Year Award for Southern California (1999)\n• Los Angeles Gay and Lesbian Center's Rand Schrader Award (1999)\n• Los Angeles Business Journal's Technology Leader of the Year (2000)\n• ACLU Citizen Advocate Award (2002)\n• amfAR Award of Courage (2006)\n• Los Angeles City of Angels Award (2008)\n• GLSEN's Lifetime Achievement Award (2009)\n• Honorary doctorate of Humane Letters from Whittier College (2012)\n• American Jewish Committee Los Angeles' Ira E. Yellin Community Leadership Award (2014)\n• Brady Bear Award from the Brady Center to Prevent Gun Violence (2016)\n• Los Angeles Business Journal's LA 500: The Most Influential People in Los Angeles (2017)" + }, + "Jennifer Pritzker": { + "Known for": "Founder and CEO of Tawani Enterprises", + "About": "**Jennifer Natalya Pritzker** popularly known as Jennifer Pritzker. A retired Lieutenant Colonel of the US Army, and Founder and CEO of private wealth management firm Tawani Enterprises, Jennifer Natalya Pritzker, is an American investor, philanthropist, member of the Pritzker family, and the world’s first transgender billionaire. Having retired from the US Army in 2001, Jennifer was promoted to the honorary rank of Colonel in the Illinois Army National Guard.\n\nFormerly known as James Nicholas Pritzker, she legally changed her official name to Jennifer Natalya Pritzker in 2013, identifying herself as a woman for all business and personal undertakings, as per an announcement shared with employees of the Pritzker Military Library and Tawani Enterprises.\n\nPritzker in 1995 founded the Tawani Foundation aiming “to enhance the awareness and understanding of the importance of the Citizen Soldier; to preserve unique sites of significance to American and military history; to foster health and wellness projects for improved quality of life; and to honor the service of military personnel, past, present and future.”", + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/62/Jennifer_Pritzker.jpg/220px-Jennifer_Pritzker.jpg", + "Born": "On August 13, 1950 at Chicago, Illinois.", + "Awards": "• First transgender billionaire\n• Founder of Tawani Foundation and Pritzker Military Library" + }, + "Claudia Brind-Woody": { + "Known for": "VP and managing director of intellectual property IBM", + "About": "**Claudia Lavergne Brind-Woody** popularly known as Claudia Brind-Woody. Global Co-Chair for the LGBT Executive Task-force at IBM, Claudia Brind-Woody is a force of nature to reckon with. In 2019, she was named among the most powerful LGBTQ+ people in tech, in addition to being in Financial Times Top 50 Outstanding list in 2013, 2014 and 2015, The Guardian’s 100 most influential LGBT people of the year in 2012, and winning the Out & Equal Trailblazer award in 2011, among other accolades.\n\nShe came out as a lesbian in the early years of her career and strives to work towards equality at the workplace. In an interview back in 2016 she shared, “What the LGBT+ community wants is to just want it to be ordinary [to be LGBT+] so that you are just seen to be valued on merit and what you bring to the business without someone thinking twice about you being LGBT+....When our employees don't have to think twice about struggling for the same benefits, recognition, or are afraid of being safe, then productivity goes up.”", + "url": "https://image.insider.com/580e9350dd089551098b47ff?width=750&format=jpeg&auto=webp", + "Born": "On January 30, 1955 at Virginia, U.S.", + "Awards": "• Out & Equal Trailblazer Award (2011)\n• GO Magazine's 100 Women We Love (2011)\n• The Guardian's World Pride Power List Top 100 (2012)\n• The Financial Times' Top 50 Outstanding list (2013, 2014, 2015)\n• The Daily Telegraph's Top 50 list of LGBT executives (2015)\n• The Financial Times' Hall of Fame (2016)\n• Diva power list (2016)\n• Business Insider The 23 Most Influential LGBTQ+ People in Tech" + }, + "Laxmi Narayan Tripathi": { + "Known for": "Humans rights activist and founder, Astitva trust", + "About": "The first transgender individual to represent APAC in the UN task meeting in 2008, representative of APAC yet again at the 20th International AIDS Conference in Melbourne and recipient of the ‘Indian of the Year’ award in 2017, Lakshmi Narayan Tripathi is a transgender activist, and part of the team that led the charge to getting transgender recognized as a third gender in India by the Supreme Court in 2014.\n\nLakshmi was appointed as the President of the NGO DAI Welfare Society in 2002, the first registered and working organization for eunuchs in South Asia. By 2007 she founded her own organization, Astitiva that works towards the welfare, support and development of sexual minorities.\n\nWith the background of an abusive childhood and being bullied for being feminine, she stated in an interview, “I chose not to remember the prejudice,” adding, “Rather I think (about) the good things that have happened to me, and be a flamboyant rainbow.”", + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/bd/Laxmi_Narayan_Tripathi_at_JLF_Melbourne_presented_by_Melbourne_Writers_Festival%2C_Federation_Square%2C_Melbourne_2017.jpg/220px-Laxmi_Narayan_Tripathi_at_JLF_Melbourne_presented_by_Melbourne_Writers_Festival%2C_Federation_Square%2C_Melbourne_2017.jpg", + "Born": "On 13th Dec 1978 at Thane", + "Awards": "• Awarded 'Indian of the Year 2017" + }, + "Tim Gill": { + "Known for": "Founder of Quark", + "About": "Tim Gill founded Quark Inc in 1981 and sold his stakes in Quark in 1999 in order to focus more on his interests in LGBT+ activism and philanthropy. He founded the pro-LGBT Gill Foundation in 1994, and since its inception it has invested more than $357 Mn in programs and non-profits around the country, substantially contributing towards many victories for LGBT community.\n\nGill married Scott Miller in 2009 and continues to be the largest donor for LGBT initiatives in America.\n\n“The LGBTQ movement has no Martin Luther King. We never have. And we probably never will,” Gill said. “So it’s not going to be grandiose gestures and big speeches and things like that that secure us equal opportunity. It will be the hard work of thousands and thousands of people over many, many years.”", + "url": "https://gillfoundation.org/wp-content/uploads/2014/09/tim-gill-20151.jpg", + "Born": "On October 18, 1953 at Hobart, Indiana", + "Awards": "• Gill was awarded the NOGLSTP GLBT Engineer of the Year Award in 2007." + } +} diff --git a/bot/resources/pride/anthems.json b/bot/resources/pride/anthems.json deleted file mode 100644 index fd8e8b92..00000000 --- a/bot/resources/pride/anthems.json +++ /dev/null @@ -1,455 +0,0 @@ -[ - { - "title": "Bi", - "artist": "Alicia Champion", - "url": "https://www.youtube.com/watch?v=HekhW9STg58", - "genre": [ - "pop" - ] - }, - { - "title": "Queer Kidz", - "artist": "Ashby and the Oceanns", - "url": "https://www.youtube.com/watch?v=tSdCciMIlO8", - "genre": [ - "folk" - ] - }, - { - "title": "I Like Boys", - "artist": "Todrick Hall", - "url": "https://www.youtube.com/watch?v=RIbGksV3YBY", - "genre": [ - "dance", - "electronic" - ] - }, - { - "title": "It Girl", - "artist": "Mister Wallace", - "url": "https://www.youtube.com/watch?v=NEnmporrBuo", - "genre": [ - "dance", - "electronic" - ] - }, - { - "title": "Gay Sex", - "artist": "Be Steadwell", - "url": "https://www.youtube.com/watch?v=XnbQu_pzf8o", - "genre": [ - "pop" - ] - }, - { - "title": "Pynk", - "artist": "Janelle Monae", - "url": "https://www.youtube.com/watch?v=PaYvlVR_BEc", - "genre": [ - "rnb", - "rhythm and blues", - "r&b", - "soul" - ] - }, - { - "title": "I don't do boys", - "artist": "Elektra", - "url": "https://www.youtube.com/watch?v=MxAvsYrHOmI", - "genre": [ - "rock", - "pop" - ] - }, - { - "title": "Girls Like Girls", - "artist": "Hayley Kiyoko", - "url": "https://www.youtube.com/watch?v=I0MT8SwNa_U", - "genre": [ - "pop", - "electropop" - ] - }, - { - "title": "Girls/Girls/Boys", - "artist": "Panic! at the Disco", - "url": "https://www.youtube.com/watch?v=Yk8jV7r6VMk", - "genre": [ - "alt", - "alternative", - "indie", - "new-wave", - "electropop", - "pop", - "rock" - ] - }, - { - "title": "I Will Survive", - "artist": "Gloria Gaynor", - "url": "https://www.youtube.com/watch?v=gYkACVDFmeg", - "genre": [ - "jazz", - "disco", - "rnb", - "r&b", - "rhythm and blues", - "soul", - "dance", - "electronic", - "pop" - ] - }, - { - "title": "Born This Way", - "artist": "Lady Gaga", - "url": "https://www.youtube.com/watch?v=wV1FrqwZyKw", - "genre": [ - "pop", - "electropop" - ] - }, - { - "title": "Raise Your Glass", - "artist": "P!nk", - "url": "https://www.youtube.com/watch?v=XjVNlG5cZyQ", - "genre": [ - "pop", - "rock", - "pop-rock" - ] - }, - { - "title": "We R Who We R", - "artist": "Ke$ha", - "url": "https://www.youtube.com/watch?v=mXvmSaE0JXA", - "genre": [ - "pop", - "dance-pop" - ] - }, - { - "title": "I'm Coming Out", - "artist": "Diana Ross", - "url": "https://www.youtube.com/watch?v=zbYcte4ZEgQ", - "genre": [ - "disco", - "funk", - "soul" - ] - }, - { - "title": "She Keeps Me Warm", - "artist": "Mary Lambert", - "url": "https://www.youtube.com/watch?v=NhqH-r7Xj0E", - "genre": [ - "pop" - ] - }, - { - "title": "June", - "artist": "Florence + The Machine", - "url": "https://www.youtube.com/watch?v=Sosmd6RjeA0", - "genre": [ - "alt", - "indie", - "alternative" - ] - }, - { - "title": "Do I Wanna Know", - "artist": "MS MR", - "url": "https://youtu.be/0DCDf1O4e1Q", - "genre": [ - "indie", - "indie-pop" - ] - }, - { - "title": "Delilah", - "artist": "Florence + The Machine", - "url": "https://www.youtube.com/watch?v=zZr5Tid3Qw4", - "genre": [ - "alt", - "alternative", - "indie" - ] - }, - { - "title": "Queen", - "artist": "Janelle Monae", - "url": "https://www.youtube.com/watch?v=tEddixS-UoU", - "genre": [ - "neo-soul" - ] - }, - { - "title": "Aesthetic", - "artist": "Hi, I'm Case", - "url": "https://www.youtube.com/watch?v=cgq-XaSC1aY", - "genre": [ - "pop", - "pop-rock" - ] - }, - { - "title": "Break Free", - "artist": "Queen", - "url": "https://www.youtube.com/watch?v=f4Mc-NYPHaQ", - "genre": [ - "rock", - "synth-pop" - ] - }, - { - "title": "LGBT", - "artist": "CupcakKe", - "url": "https://www.youtube.com/watch?v=U_OArkw5yeQ", - "genre": [ - "hip-hop", - "rap" - ] - }, - { - "title": "Rainbow Connections", - "artist": "Garfunkel and Oates", - "url": "https://www.youtube.com/watch?v=MneRtx7x2vs", - "genre": [ - "folk" - ] - }, - { - "title": "Proud", - "artist": "Heather Small", - "url": "https://www.youtube.com/watch?v=LEoxGJ79PMs", - "genre": [ - "dance-pop", - "r&b", - "rhythm and blues", - "rnb" - ] - }, - { - "title": "LGBT", - "artist": "Lowell", - "url": "https://www.youtube.com/watch?v=QgE6nZmTGLw", - "genre": [ - "alternative", - "indie", - "alt", - "pop" - ] - }, - { - "title": "Kiss the Boy", - "artist": "Keiynan Lonsdale", - "url": "https://www.youtube.com/watch?v=bXzLZ7QQnpQ", - "genre": [ - "pop" - ] - }, - { - "title": "Boys Aside", - "artist": "Sofya Wang", - "url": "https://www.youtube.com/watch?v=NlAW7l6dmeA", - "genre": [ - "pop" - ] - }, - { - "title": "Everyone is Gay", - "artist": "A Great Big World", - "url": "https://www.youtube.com/watch?v=0VG1bj4Lj1Q", - "genre": [ - "pop" - ] - }, - { - "title": "The Queer Gospel", - "artist": "Erin McKeown", - "url": "https://www.youtube.com/watch?v=2vvOEoT-q_o", - "genre": [ - "christian", - "gospel" - ] - }, - { - "title": "Girls", - "artist": "Girl in Red", - "url": "https://www.youtube.com/watch?v=_BMBDY01kPk", - "genre": [ - "alternative", - "indie", - "alt" - ] - }, - { - "title": "Crazy World", - "artist": "MNEK", - "url": "https://www.youtube.com/watch?v=YBwzTgNL-zw", - "genre": [ - "pop" - ] - }, - { - "title": "Pride", - "artist": "Grace Petrie", - "url": "https://www.youtube.com/watch?v=y5rMrPJzFGs", - "genre": [ - "alt", - "alternative", - "indie" - ] - }, - { - "title": "Good Guys", - "artist": "MIKA", - "url": "https://www.youtube.com/watch?v=VZQ_9eebry0", - "genre": [ - "pop" - ] - }, - { - "title": "Gender is Boring", - "artist": "She/Her/Hers", - "url": "https://www.youtube.com/watch?v=glJW2vlBAQg", - "genre": [ - "punk" - ] - }, - { - "title": "GUY.exe", - "artist": "Superfruit", - "url": "https://www.youtube.com/watch?v=r2Kh_XMIDPU", - "genre": [ - "pop" - ] - }, - { - "title": "That's So Gay", - "artist": "Pansy Division", - "url": "https://www.youtube.com/watch?v=xlpcyeadaTk", - "genre": [ - "rock" - ] - }, - { - "title": "Strangers", - "artist": "Halsey", - "url": "https://www.youtube.com/watch?v=RVd_71ZdRd4", - "genre": [ - "pop", - "alt", - "alternative", - "indie", - "electropop" - ] - }, - { - "title": "LGBTQIA", - "artist": "Matt Fishel", - "url": "https://www.youtube.com/watch?v=KQq9f5hNOxE", - "genre": [ - "rock" - ] - }, - { - "title": "Tell Me a Story", - "artist": "Skylar Kergil", - "url": "https://www.youtube.com/watch?v=nbQDTE2s3dI", - "genre": [ - "folk" - ] - }, - { - "title": "Trans Is Love", - "artist": "Marissa Kay", - "url": "https://www.youtube.com/watch?v=-5f_1H0RD1I", - "genre": [ - "alt", - "alternative", - "indie", - "alt-country", - "alt-folk", - "indie-rock", - "new-southern-rock" - ] - }, - { - "title": "You Can't Tell Me", - "artist": "Jake Edwards", - "url": "https://www.youtube.com/watch?v=CwqDG5269Ak", - "genre": [ - "pop" - ] - }, - { - "title": "Closet Femme", - "artist": "Kate Reid", - "url": "https://www.youtube.com/watch?v=va-nqcNxP_k", - "genre": [ - "folk" - ] - }, - { - "title": "Let's Have a Kiki", - "artist": "Scissor Sisters", - "url": "https://www.youtube.com/watch?v=eGCD4xb-Tr8", - "genre": [ - "electropop", - "pop" - ] - }, - { - "title": "Gimme Gimme Gimme", - "artist": "ABBA", - "url": "https://www.youtube.com/watch?v=JWay7CDEyAI", - "genre": [ - "disco" - ] - }, - { - "title": "Dancing Queen", - "artist": "ABBA", - "url": "https://www.youtube.com/watch?v=xFrGuyw1V8s", - "genre": [ - "disco", - "europop", - "euero-disco" - ] - }, - { - "title": "City Grrl", - "artist": "CSS", - "url": "https://www.youtube.com/watch?v=duOA3FgpZqY", - "genre": [ - "electropop" - ] - }, - { - "title": "Blame it on the Girls", - "artist": "MIKA", - "url": "https://www.youtube.com/watch?v=iF_w7oaBHNo", - "genre": [ - "pop", - "pop-rock" - ] - }, - { - "title": "Bye Bye Bye", - "artist": "*NSYNC", - "url": "https://www.youtube.com/watch?v=Eo-KmOd3i7s", - "genre": [ - "pop", - "europop" - ] - }, - { - "title": "Gettin Bi", - "artist": "Crazy Ex-Girlfriend", - "url": "https://www.youtube.com/watch?v=5e7844P77Is", - "genre": [ - "pop" - ] - } -] diff --git a/bot/resources/pride/drag_queen_names.json b/bot/resources/pride/drag_queen_names.json deleted file mode 100644 index 023dff36..00000000 --- a/bot/resources/pride/drag_queen_names.json +++ /dev/null @@ -1,249 +0,0 @@ -[ - "Adelle Lectible", - "Adelle Light", - "Adelle Lirious", - "Alison Wonder", - "Amie Thyst", - "Amie Zonite", - "Angela Develle", - "Anna Conda", - "Anne Amaley", - "Annie Nigma", - "Aria Hymn", - "Aria Viderci", - "Aroa Mattic", - "Aster Starr", - "Aura Aurora", - "Aura Ley", - "Aurora Dorea", - "Barba Rouse", - "Bea Constrictor", - "Bella Lush", - "Belle Icoza", - "Belle Ligerrente", - "Betty Brilliance", - "Bo Deysious", - "Carol Chorale", - "Cecil Clouds", - "Cecil Sunshine", - "Celeste Booday", - "Chichi Swank", - "Claire Geeman", - "Claire Rickal", - "Claire Voyance", - "Cleo Patrix", - "Connie Fidence", - "Corra Rageous", - "Daye Light", - "Deedee Cation", - "Deedee Sign", - "Dianne Gerous", - "Didi Divine", - "Diemme Monds", - "Dorothy Doughty", - "Dutches Dauntless", - "Ella Gance", - "Ella Gants", - "Ella Menterry", - "Ella Stique", - "Elle Lectrick", - "Elle Lure", - "Emma Geddon", - "Emma Phasis", - "Emma Rald", - "Emme Plosion", - "Emme Pulse", - "Emme Vention", - "Enna Fincible", - "Enne Phinite", - "Enne Treppide", - "Etha Nitty", - "Etha Reyal", - "Euphoria Bliss", - "Eva Nessent", - "Eve Forric", - "Eve Ningowne", - "Eve Ville", - "Faith Lesse", - "Faschia Nation", - "Faye Boulous", - "Faye Lacious", - "Faye Minine", - "Faye Nixx", - "Felicity Spice", - "Freya Domme", - "Gal Gallant", - "Gal Galore", - "Gal Lante", - "Gemma Safir", - "Gena Rocity", - "Genna Russ", - "Gigi Lamour", - "Gigi Rand", - "Glemma Rouss", - "Grace Iyus", - "Haye Light", - "Hazel Nutt", - "Hella Billy", - "Hella Centrique", - "Hella Cious", - "Hella Riouss", - "Hella Whole", - "Hellen Back", - "Herra Zee", - "Ina Creddeble", - "Ina Fernalle", - "Jo Nee", - "Jo Phial", - "Joye Ryde", - "Jue Cee", - "Jue Wells", - "Juju Bee", - "Kaia Cayenne", - "Kaye Bye", - "Kitsch Kitsch Bang Bang", - "Lady Lace", - "Lavish Lazuli", - "Lea Ness", - "Leye Berty", - "Lisse Truss", - "Liv Lee", - "Lola Lavish", - "Lolo Yaltie", - "Lucy Fur", - "Lucy Luck", - "Lulu LaBye", - "Lulu Xuri", - "Lunaye Clipse", - "Lyra Kall", - "Maggie Magma", - "Mara Bells", - "Marry Golds", - "Marry Nayde", - "Marry Sipan", - "Marve Vellus", - "Mary Ganal", - "Mary Malade", - "May Jestic", - "May Lancholly", - "May Licious", - "May Lodi", - "May Morable", - "May Stirius", - "May Varlous", - "Melody Gale", - "Melody Toune", - "Miss Adora", - "Miss Alure", - "Miss Chieff", - "Miss Fortune", - "Miss Mash", - "Miss Mood", - "Miss Nomer", - "Miss Sanguine", - "Miss Sublime", - "Mistress Galore", - "Monique Mystique", - "Morgan Fatana", - "Nashay Kitt", - "Nicole Lorful", - "Noë Stalgia", - "Ora Kelle", - "Ora Nate", - "Patty Siyens", - "Penny Laized", - "Penny Ramma", - "Penny Rammic", - "Penny Talloons", - "Percey Ferance", - "Perry Fomance", - "Phara Waye", - "Phata Morgana", - "Pho Latyle", - "Pho Lume", - "Phoebe Rant", - "Phoenix Bright", - "Pippa Pepper", - "Pippa Pizazz", - "Polly Tickle", - "Poppy Corn", - "Poppy Cox", - "Poppy Domm", - "Poppy Larr", - "Poppy Lerry", - "Poppy Sickles", - "Portia Bella", - "Portia Nette", - "Pria Steegious", - "Pria Steen", - "Prissa Teen", - "Raye Bitt", - "Raye Diante", - "Raye Nessance", - "Raye Storm", - "Remi Nissent", - "Rey Mantique", - "Rey Markeble", - "Rey Moorse", - "Rey Torric", - "Rococo Jazz", - "Roma Ence", - "Rose Budd", - "Ruby Redd", - "Ruby Ree", - "Ruth Lezz", - "Sall Laikeen", - "Sall Lay", - "Sally Ness", - "Sam Armie", - "Sam Ooth", - "Sara Castique", - "Sara Donique", - "Sara Penth", - "Sarah Pentine", - "Sarah Reen", - "Sasha Sass", - "Satty Phection", - "Sella Fish", - "Sella Stice", - "Selly Foxx", - "Senna Guinne", - "Senna Seer", - "Shia Mirring", - "Sia Dellic", - "Sia Dowe", - "Siam Pathy", - "Silver Foxx", - "Siri Price", - "Sofie Moore", - "Sofie Stication", - "Su Blime", - "Sue Burben", - "Sue Missif", - "Sue Pernova", - "Sue Preem", - "Super Nova", - "Suse Pense", - "Suzu Blime", - "Temma Tation", - "Tempest Wilde", - "Terra Gique", - "Thea Terre", - "Tina Cious", - "Tina Scious", - "Tira Mendus", - "Tira Quoise", - "Trinity Quart", - "Trixie Foxx", - "Tye Gress", - "Tye Phun", - "Vall Canno", - "Vall Iant", - "Vall Orous", - "Vanity Fairchild", - "Vicki Tory", - "Vivi Venus", - "Vivian Foxx", - "Vye Vacius", - "Zahara Dessert" -] diff --git a/bot/resources/pride/facts.json b/bot/resources/pride/facts.json deleted file mode 100644 index 2151f5ca..00000000 --- a/bot/resources/pride/facts.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "2020": [ - "No research has conclusively proven what causes homosexuality, heterosexuality, or bisexuality.", - "Records of same-sex relationships have been found in nearly every culture throughout history with varying degrees of acceptance.", - "Various slurs targeting queer people have been reappropriated by them, notably \"dyke\", and \"queer\".", - "Historians note that in some cultures, some homosexual behavior was not viewed as effeminate, but as evidence of a man's masculinity. Examples include the Celtic and Greek cultures.", - "Over time, the proportion of people who identify as homosexual or bisexual appears to be increasing. It is not know if this is because they feel it is safer to come out, or if the actual numbers of homosexual/bisexual people are increasing.", - "A large proportion of people, both in and out of the LGBTQ+ communities, do not believe bisexuality exists. This is known as bisexual erasure.", - "Queer people commit suicide are much more common in politically conservative regions, and also more common than non-queer people in general.", - "October 8th is lesbian pride day!", - "Stormé DeLarverie, a lesbian drag king, had a \"scuffle\" with the police which many claim is what kicked off the Stonewall Riots.", - "Gilbert Baker, also known as the “Gay Betsy Ross,” designed the rainbow flag, or Pride Flag, in San Francisco in 1978.", - "The rainbow pride flag is well-known, but there are flags for most labeled gender/sexual minorities.", - "In 1968, Dr. John Money performed the first complete male-to-female sex-change operation in the United States at Johns Hopkins University.", - "At the age of 24, Leonardo Da Vinci was arrested for having sex with a man. He was eventually acquitted.", - "Alfred Kinsey, the creator of the Kinsey scale, is known as \"the father of the sexual revolution\". The Kinsey scale was created in order to demonstrate that sexuality does not fit into two strict categories: homosexual and heterosexual. Instead, Kinsey believed that sexuality is fluid and subject to change over time.", - "The Kinsey scale ranges from 0, which is exclusively heterosexual, to 6, which is exclusively homosexual.", - "November 20th is the Transgender Day of Remembrance, which is a day to memorialize those who have been murdered as a result of transphobia.", - "The pink triangle was the symbol that queer people were required to wear in Nazi concentration camps during WWII. The symbol has since been reclaimed as a positive symbol of self-identity.", - "The term \"AC/DC\" has been used to refer to bisexuals.", - "September 23rd is bisexual pride day!", - "Pride Day refers to celebrations that typically take place in June that commemorate the Stonewall Inn riots of June 28, 1969. These riots are considered the birth of the modern gay civil rights movement.", - "A \"beard\" is someone of the opposite sex who knowingly dates a closeted lesbian or gay man to provide that person with a heterosexual \"disguise\", usually for family or career purposes.", - "In Nigeria, where homosexuality is punishable by death by stoning, a post-grad student claimed he had proved being gay was wrong by using magnets. He hoped to win a Nobel Prize for his research. He has not received one.", - "In 1982, the Gay Related Immune Disorder (GRID) was renamed Acquired Immune Deficiency Syndrome (AIDS).", - "The word \"lesbian\" is derived from the Greek island Lesbos, home of Greek poet Sappho. Her poetry proclaimed her love for women, and their beauty.", - "Nearly all bonobos (a kind of chimpanzee) appear to be bisexual.", - "Homosexual behavior has been observed in 1,500 animal species and is most widespread among animals with a complex herd life.", - "Many queer people identify their sexual orientation independently from their romantic orientation. For instance, it is possible to be sexually attracted to both women and men, but only be romantically attracted to one of them.", - "In 2005, Swedish researchers found that when straight men smelled a female urine compound, their hypothalamus lit up in brain images. In gay men, it did not. Instead, homosexual men's hypothalamus lit up when they smelled the male-sweat compound, which was the same way straight women responded.", - "As of 2019-10-02, there are 17 states in the United States of America where queer people can be fired for being queer. In most other states, there is minimal protection offered, often only for public employees.", - "In 1985, an official Star Trek novel was published with scenes depicting Kirk and Spock as lovers. These parts were largely removed, which made the original into a collector's item." - ] -} diff --git a/bot/resources/pride/flags/agender.png b/bot/resources/pride/flags/agender.png deleted file mode 100644 index 8a09e5fa..00000000 Binary files a/bot/resources/pride/flags/agender.png and /dev/null differ diff --git a/bot/resources/pride/flags/androgyne.png b/bot/resources/pride/flags/androgyne.png deleted file mode 100644 index da40ec01..00000000 Binary files a/bot/resources/pride/flags/androgyne.png and /dev/null differ diff --git a/bot/resources/pride/flags/aromantic.png b/bot/resources/pride/flags/aromantic.png deleted file mode 100644 index 7c42a200..00000000 Binary files a/bot/resources/pride/flags/aromantic.png and /dev/null differ diff --git a/bot/resources/pride/flags/asexual.png b/bot/resources/pride/flags/asexual.png deleted file mode 100644 index c339b239..00000000 Binary files a/bot/resources/pride/flags/asexual.png and /dev/null differ diff --git a/bot/resources/pride/flags/bigender.png b/bot/resources/pride/flags/bigender.png deleted file mode 100644 index 9864f9bb..00000000 Binary files a/bot/resources/pride/flags/bigender.png and /dev/null differ diff --git a/bot/resources/pride/flags/bisexual.png b/bot/resources/pride/flags/bisexual.png deleted file mode 100644 index 2479bc8e..00000000 Binary files a/bot/resources/pride/flags/bisexual.png and /dev/null differ diff --git a/bot/resources/pride/flags/demiboy.png b/bot/resources/pride/flags/demiboy.png deleted file mode 100644 index 95f68717..00000000 Binary files a/bot/resources/pride/flags/demiboy.png and /dev/null differ diff --git a/bot/resources/pride/flags/demigirl.png b/bot/resources/pride/flags/demigirl.png deleted file mode 100644 index 6df49bce..00000000 Binary files a/bot/resources/pride/flags/demigirl.png and /dev/null differ diff --git a/bot/resources/pride/flags/demisexual.png b/bot/resources/pride/flags/demisexual.png deleted file mode 100644 index 5339330e..00000000 Binary files a/bot/resources/pride/flags/demisexual.png and /dev/null differ diff --git a/bot/resources/pride/flags/gay.png b/bot/resources/pride/flags/gay.png deleted file mode 100644 index 5a454ca3..00000000 Binary files a/bot/resources/pride/flags/gay.png and /dev/null differ diff --git a/bot/resources/pride/flags/genderfluid.png b/bot/resources/pride/flags/genderfluid.png deleted file mode 100644 index ac22f093..00000000 Binary files a/bot/resources/pride/flags/genderfluid.png and /dev/null differ diff --git a/bot/resources/pride/flags/genderqueer.png b/bot/resources/pride/flags/genderqueer.png deleted file mode 100644 index 4652c7e6..00000000 Binary files a/bot/resources/pride/flags/genderqueer.png and /dev/null differ diff --git a/bot/resources/pride/flags/intersex.png b/bot/resources/pride/flags/intersex.png deleted file mode 100644 index c58a3bfe..00000000 Binary files a/bot/resources/pride/flags/intersex.png and /dev/null differ diff --git a/bot/resources/pride/flags/lesbian.png b/bot/resources/pride/flags/lesbian.png deleted file mode 100644 index 824b9a89..00000000 Binary files a/bot/resources/pride/flags/lesbian.png and /dev/null differ diff --git a/bot/resources/pride/flags/nonbinary.png b/bot/resources/pride/flags/nonbinary.png deleted file mode 100644 index ee3c50e2..00000000 Binary files a/bot/resources/pride/flags/nonbinary.png and /dev/null differ diff --git a/bot/resources/pride/flags/omnisexual.png b/bot/resources/pride/flags/omnisexual.png deleted file mode 100644 index 2527051d..00000000 Binary files a/bot/resources/pride/flags/omnisexual.png and /dev/null differ diff --git a/bot/resources/pride/flags/pangender.png b/bot/resources/pride/flags/pangender.png deleted file mode 100644 index 38004654..00000000 Binary files a/bot/resources/pride/flags/pangender.png and /dev/null differ diff --git a/bot/resources/pride/flags/pansexual.png b/bot/resources/pride/flags/pansexual.png deleted file mode 100644 index 0e56b534..00000000 Binary files a/bot/resources/pride/flags/pansexual.png and /dev/null differ diff --git a/bot/resources/pride/flags/polyamory.png b/bot/resources/pride/flags/polyamory.png deleted file mode 100644 index b41f061f..00000000 Binary files a/bot/resources/pride/flags/polyamory.png and /dev/null differ diff --git a/bot/resources/pride/flags/polysexual.png b/bot/resources/pride/flags/polysexual.png deleted file mode 100644 index b2aba22c..00000000 Binary files a/bot/resources/pride/flags/polysexual.png and /dev/null differ diff --git a/bot/resources/pride/flags/transgender.png b/bot/resources/pride/flags/transgender.png deleted file mode 100644 index 73f01043..00000000 Binary files a/bot/resources/pride/flags/transgender.png and /dev/null differ diff --git a/bot/resources/pride/flags/trigender.png b/bot/resources/pride/flags/trigender.png deleted file mode 100644 index 06ff0f7c..00000000 Binary files a/bot/resources/pride/flags/trigender.png and /dev/null differ diff --git a/bot/resources/pride/gender_options.json b/bot/resources/pride/gender_options.json deleted file mode 100644 index 062742fb..00000000 --- a/bot/resources/pride/gender_options.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "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" -} diff --git a/bot/resources/pride/prideleader.json b/bot/resources/pride/prideleader.json deleted file mode 100644 index 30e84bdc..00000000 --- a/bot/resources/pride/prideleader.json +++ /dev/null @@ -1,100 +0,0 @@ -{ - "Tim Cook": { - "Known for": "CEO of Apple", - "About": "**Timothy Donald Cook** popularly known as Tim Cook. Despite being a notably private person, Tim Cook became the first CEO of a 500 fortune company, coming out as gay in 2014. He revealed his sexual orientation through an open letter published in Bloomberg BusinessWeek. He strongly believes that business leaders need to do their part to make the world a better place. An excerpt from his open letter is as follows: “Part of social progress is understanding that a person is not defined only by one's sexuality, race, or gender.", - "url": "https://image.cnbcfm.com/api/v1/image/105608434-1543945658496rts28qzc.jpg?v=1554921416&w=1400&h=950", - "Born": "In November 1, 1960 at Mobile, Alabama, U.S.", - "Awards": "• Financial Times Person of the Year (2014)\n• Ripple of Change Award (2015)\n• Fortune Magazine's: World's Greatest Leader. (2015)\n• Alabama Academy of Honor: Inductee. (2015)\n• Human Rights Campaign's Visibility Award (2015)\n• Honorary Doctor of Science from University of Glasgow in Glasgow, Scotland (2017)\n• Courage Against Hate award from Anti-Defamation League (2018)" - }, - "Alan Joyce": { - "Known for": "CEO of Qantas Airlines", - "About": "**Alan Joseph Joyce, AC** popularly known as Alan Joyce. Alan Joyce has been named as one of the world’s most influential business executives. He has always been very open about his orientation and has been in a committed gay relationship for over 20 years now. He supports same-sex marriage and believes that it is critical to make people recognize that they know LGBT people\n\nAlan likes to frequently talk about equality at the workplace and said, “It has become more important for leaders who are LGBT to be open about their sexuality. I am passionate about it. There should be more people like Apple’s Tim Cook and Paul Zahra, the former CEO of Australia’s David Jones [store chain].”", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/83/Alan_Joyce_%28cropped%29.jpg/220px-Alan_Joyce_%28cropped%29.jpg", - "Born": "On 30 June, 1966 at Tallaght, Dublin, Republic of Ireland", - "Awards": "• The Australian named Joyce the most influential business leader in 2011\n• Joyce is an Ambassador of the Australian Indigenous Education Foundation\n• Joyce named a Companion of the Order of Australia, Australia's highest civil honour, in the 2017 Queen's birthday honours list" - }, - "Peter Thiel": { - "Known for": "Co-Founder and Former CEO of paypal", - "About": "**Peter Andreas Thiel** popularly known as Peter Thiel. Peter Thiel served as the CEO of PayPal from its inception to its sale to eBay in October 2002. Thiel’s sexuality came out in 2007 when Gawker Media outed him in a blog post. He became the first openly gay speaker at Republican National Convention and delivered a speech on sexuality.\n\n“Of course every American has a unique identity,” he said. “I am proud to be gay. I am proud to be a Republican. But most of all I am proud to be an American.”", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3f/Peter_Thiel_2014_by_Heisenberg_Media.jpg/220px-Peter_Thiel_2014_by_Heisenberg_Media.jpg", - "Born": "On 11 October, 1967 at Frankfurt, West Germany", - "Awards": "• Thiel received a co-producer credit for Thank You for Smoking, a 2005 feature film based on Christopher Buckley's 1994 novel of the same name\n• In 2006, Thiel won the Herman Lay Award for Entrepreneurship\n• In 2007, he was honored as a Young Global leader by the World Economic Forum as one of the 250 most distinguished leaders age 40 and under\n• On 7 November 2009, Thiel was awarded an honorary degree from Universidad Francisco Marroquin\n• In 2012, Students For Liberty, an organization dedicated to spreading libertarian ideals on college campuses, awarded Thiel its “Alumnus of the Year” award\n• In February 2013, Thiel received a TechCrunch Crunchie Award for Venture Capitalist of the Year.\n• Insurance Executive of the Year: St Joseph’s University’s Academy of Risk Management and Insurance in Philadelphia" - }, - "Martine Rothblatt": { - "Known for": "CEO of United Therapeutics", - "About": "**Martine Aliana Rothblatt** popularly known as Martine Rothblatt. Martine co-founded Sirius XM Satellite Radio in 1990 and 1996 founded United Therapeutics, making her the highest-earning CEO in the biopharmaceutical industry. She came out as a transgender in 1994 and has been vocal about the trans community ever since.\n\nIn 2014 in an interview, Martine said, “I took a journey from male to female, so if I hide that, I’m, like just replicating the closet of my past with another closet of the future. That made no sense and that is why I am open.” She has authored a book called “The apartheid of Sex”.", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/bd/Martine-Rothblatt2-5773.jpg/220px-Martine-Rothblatt2-5773.jpg", - "Born": "On October 10, 1954 at Chicago, Illinois, U.S.", - "Awards": "• In January 2018 Rothblatt was presented the UCLA Medal, the university's highest award, in recognition of her creation of Sirius XM satellite radio, advancing organ transplant technology, and having “expanded the way we understand fundamental concepts ranging from communication to gender to the nature of consciousness and mortality.”" - }, - "Peter Arvai": { - "Known for": "Co-Founder and CEO of Prezi", - "About": "Peter Arvai is the 11th most influential LGBT leader in the world. His goal has always been to create an open environment that fosters diversity. He has co-founded an NGO with Google and espell called ‘We Are Open’ to promote openness at the workplace. Peter regularly talks about his gay background in every on-boarding session at his company.\n\nWhen Prezi was featured on the cover of Forbes, Peter used the opportunity by coming out and sharing his story to start a conversation on this topic that most people seem to avoid. If you want to create a more inclusive workplace, you need to be willing to be vulnerable yourself, he says. “To spark honest discussions about inclusivity and openness, your personal experience of inclusion is a key resource and you need to create a safe environment so people find the courage to have uncomfortable conversations.”", - "url": "https://cached.imagescaler.hbpl.co.uk/resize/scaleWidth/880/cached.offlinehbpl.hbpl.co.uk/news/OTM/Peterarvai-20191218101617863.jpg", - "Born": "On October 26, 1979 at Karlskoga, Sweden", - "Awards": "• 2014: European Tech Startups Award for Best Startup Co-Founders.\n• 2014: European Web Entrepreneur of the Year.\n• 2015: Executive of the Year – Business Services: Bronze Stevie Winner.\n• 2016: Number 11 on the 2016 OUTstanding & Financial Times Leading LGBT Executives List of 100" - }, - "Inga Beale": { - "Known for": "CEO of Lloyd's of London", - "About": "Inga became the first female CEO of Lloyd’s of London in 2013 and in 2017 was named a Dame Commander of the Order of the British Empire for her contribution towards the British economy.\n\nShe came out first as a bisexual, in an interview in 2008 and since then has made efforts to bring diversity and inclusion at the forefront in her company. “It’s not about me. It’s about what you do for other people. For me, it’s so important because you need these role models.”\n\nSpeaking between meetings at the World Economic Forum in Davos, she says her position at the top of the LGBT table is important for its impact on others: “It’s about giving people confidence,” she says.", - "url": "https://cdn-res.keymedia.com/cms/images/us/018/0248_637072371134927741.jpeg", - "Born": "On May 15, 1963 at Great Britain", - "Awards": "• Trailblazer of the Year: The Insurance Industry Charitable Foundation (IICF)(2019)\n• Free Enterprise Award: Insurance Federation of New York (IFNY)(2018)\n• Market People - Outstanding Contribution Award: The London Market Forums(2018)\n• Outstanding Achievement Award: Insurance Day | Informa(2018)\n• Barclay's Lifetime Achievement Award: Variety Children's Charity - Catherine Awards(2017)\n• Insurance Woman of the Year Award: Association of Professional Insurance Women (APIW)(2017)\n• Dame Commander of the Order of the British Empire - DBE: HM The Queen(2017)\n• Insurance Personality of the Year: British Insurance Awards\n• Insurance Executive of the Year: St Joseph’s University’s Academy of Risk Management and Insurance in Philadelphia(2015)" - }, - "David Geffen": { - "Known for": "Founder of Dreamworks", - "About": "**David Lawrence Geffen** popularly known as David Geffen. Founder of film studio Dream Works as well as record labels Asylum Records, Geffen Records and DGC Records, David Geffen came out in 1992 at a fund raiser announcing, “As a Gay man, I have come a long way to be here tonight.” He was already among the strongest pillars of the gay rights movement by then.\n\n“If I am going to be a role model, I want to be one that I can be proud of,” Geffen said in an interview back in 1992.”\n\nGeffen contributed significantly towards society through the David Geffen Foundation that worked relentlessly towards healthcare, people affected by HIV/AIDS, civil liberties, issues of concern to the Jewish community, and arts. Interestingly, the song ‘Free man in Paris’ by Joni Mitchell is based on Geffen’s time in Paris during a trip they took together along with Canadian musician Robbie Robertson and his wife.", - "url": "https://i.insider.com/5b733a2be199f31d138b4bec?width=1100&format=jpeg&auto=webp", - "Born": "On February 21, 1943 at New York City, U.S.", - "Awards": "• Tony Award for Best Musical(1983)\n• Tony Award for Best Play(1988)\n• Daytime Emmy Award for Outstanding Children's Animated Program(1990)" - }, - "Joel Simkhai": { - "Known for": "Founder and former CEO of Grindr and Blendr", - "About": "Joel Simkhai founded Grindr, a dating app for men in the LGBTQ+ community in 2009. He says he launched the app with a “selfish desire’ to meet more gay men. Today, Grindr has over 4 million users and has become the world's largest social networking platform for men from the LGBTQ+ community to interact.\n\nIn an interview Joel shared, “ As a kid I was teased, teased for being feminine, I guess for being gay. So a lot of my early life was just in denial about it with myself and others. But by 16 and as I started getting older, I realized that I like guys”. “My story is the story of every gay man. We are seen as a minority in some ways and the services out there weren’t that great. I am an introvert so I don’t really do well at being who I am right away but online seemed like my comfort zone”. It all begins with meeting someone, he adds.", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/44/Joel_Simkhai_2012_%28cropped%29.jpg/220px-Joel_Simkhai_2012_%28cropped%29.jpg", - "Born": "On 1976 at Tel Aviv, Israel", - "Awards": "• Simkhai’s company has been mentioned in pop culture icons like The Office, Saturday Night Live, Judge Judy and Top Gear and won a number of industry awards including “Best Mobile Dating App” in 2011 and 2012 and “Best New Technology” in 2012 from the iDate Awards and “Best Location Application” at the TechCrunch Awards in 2011." - }, - "Megan Smith": { - "Known for": "Former CTO of United States", - "About": "Megan Smith the former CTO of the United States has always been vocal about the need to push inclusion. Most central to her message, however, is the key insight that is most often lost: not only is inclusivity a part of technology’s future, it was also a seminal part of its past. Ada Lovelace, for example, an English woman born in 1812, was the first computer programmer; Katherine G. Johnson, an African-American woman featured in the Oscar-nominated film Hidden Figures, helped put NASA astronauts on the moon.\n\nIn 2003, in an interview Megan said, “When you are gay, you come out everyday because everyone assumes you are straight. But you have to be yourself.” Smith also hopes to open up the tech industry to more women and encourage girls to pursue a career in it.", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f0/Megan_Smith_official_portrait.jpg/220px-Megan_Smith_official_portrait.jpg", - "Born": "On October 21, 1964 at Buffalo, New York, and Fort Erie, Ontario.", - "Awards": "• World Economic Forum Technology Pioneer 2001, 2002\n• Listed by Out magazine in 2012 and 2013, as one of the 50 most powerful LGBT people in the United States\n• Reuters Digital Vision Program Fellow at Stanford, 2003-2004\n• Top 25 Women on the Web, 2000\n• Upside Magazine 100 Digital Elite, 1999 and 2000\n• Advertising Age i.20, 1999\n• GLAAD Interactive Media Award for Internet Leadership, 1999\n• Charging Buffalo Award, 2015\n• Business Insider 23 Most Powerful LGBTQ+ People in Tech, 2019" - }, - "David Bohnett": { - "Known for": "Founder of Geocities", - "About": "**David C. Bohnett** popularly known as David Bohnett. A tech entrepreneur, with his LA-based venture firm Baroda Ventures, founded in 1998, David Bohnett is also the Founder of Geocities, which remains the first largest internet venture built on user-generated content, founded in 1998 and acquired by Yahoo in 1999. Geocities was cited #2 by TechRadar in its list of ‘20 websites that changed the world’ back in 2008.\n\nBohnett came out to his family post his graduation and worked extensively towards equal rights for gays and lesbians, and towards legalizing same-sex marriage. He founded the David Bohnett Foundation, dedicated to community-building and social activism addressing concerns across a broad spectrum of arts, educational, and civic programs. The first openly bisexual US congressperson Krysten Sinema and the first openly gay mayor of a major US city (Houston), Annise Parker, are both alumnis of the LGBT Leadership Fellows run by his foundation that trains LGBT leaders for local and state governments.", - "url": "https://upload.wikimedia.org/wikipedia/commons/c/cb/David_Bohnett.jpg", - "Born": "On April 2, 1956 at Chicago, Illinois", - "Awards": "• Number 16 on Time's Top 50 Cyber Elite (1998)\n• Upside magazine's Elite 100 (1998)\n• Newsweek's “100 People to Watch in the Next Millennium”\n• Ernst & Young Entrepreneur of the Year Award for Southern California (1999)\n• Los Angeles Gay and Lesbian Center's Rand Schrader Award (1999)\n• Los Angeles Business Journal's Technology Leader of the Year (2000)\n• ACLU Citizen Advocate Award (2002)\n• amfAR Award of Courage (2006)\n• Los Angeles City of Angels Award (2008)\n• GLSEN's Lifetime Achievement Award (2009)\n• Honorary doctorate of Humane Letters from Whittier College (2012)\n• American Jewish Committee Los Angeles' Ira E. Yellin Community Leadership Award (2014)\n• Brady Bear Award from the Brady Center to Prevent Gun Violence (2016)\n• Los Angeles Business Journal's LA 500: The Most Influential People in Los Angeles (2017)" - }, - "Jennifer Pritzker": { - "Known for": "Founder and CEO of Tawani Enterprises", - "About": "**Jennifer Natalya Pritzker** popularly known as Jennifer Pritzker. A retired Lieutenant Colonel of the US Army, and Founder and CEO of private wealth management firm Tawani Enterprises, Jennifer Natalya Pritzker, is an American investor, philanthropist, member of the Pritzker family, and the world’s first transgender billionaire. Having retired from the US Army in 2001, Jennifer was promoted to the honorary rank of Colonel in the Illinois Army National Guard.\n\nFormerly known as James Nicholas Pritzker, she legally changed her official name to Jennifer Natalya Pritzker in 2013, identifying herself as a woman for all business and personal undertakings, as per an announcement shared with employees of the Pritzker Military Library and Tawani Enterprises.\n\nPritzker in 1995 founded the Tawani Foundation aiming “to enhance the awareness and understanding of the importance of the Citizen Soldier; to preserve unique sites of significance to American and military history; to foster health and wellness projects for improved quality of life; and to honor the service of military personnel, past, present and future.”", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/62/Jennifer_Pritzker.jpg/220px-Jennifer_Pritzker.jpg", - "Born": "On August 13, 1950 at Chicago, Illinois.", - "Awards": "• First transgender billionaire\n• Founder of Tawani Foundation and Pritzker Military Library" - }, - "Claudia Brind-Woody": { - "Known for": "VP and managing director of intellectual property IBM", - "About": "**Claudia Lavergne Brind-Woody** popularly known as Claudia Brind-Woody. Global Co-Chair for the LGBT Executive Task-force at IBM, Claudia Brind-Woody is a force of nature to reckon with. In 2019, she was named among the most powerful LGBTQ+ people in tech, in addition to being in Financial Times Top 50 Outstanding list in 2013, 2014 and 2015, The Guardian’s 100 most influential LGBT people of the year in 2012, and winning the Out & Equal Trailblazer award in 2011, among other accolades.\n\nShe came out as a lesbian in the early years of her career and strives to work towards equality at the workplace. In an interview back in 2016 she shared, “What the LGBT+ community wants is to just want it to be ordinary [to be LGBT+] so that you are just seen to be valued on merit and what you bring to the business without someone thinking twice about you being LGBT+....When our employees don't have to think twice about struggling for the same benefits, recognition, or are afraid of being safe, then productivity goes up.”", - "url": "https://image.insider.com/580e9350dd089551098b47ff?width=750&format=jpeg&auto=webp", - "Born": "On January 30, 1955 at Virginia, U.S.", - "Awards": "• Out & Equal Trailblazer Award (2011)\n• GO Magazine's 100 Women We Love (2011)\n• The Guardian's World Pride Power List Top 100 (2012)\n• The Financial Times' Top 50 Outstanding list (2013, 2014, 2015)\n• The Daily Telegraph's Top 50 list of LGBT executives (2015)\n• The Financial Times' Hall of Fame (2016)\n• Diva power list (2016)\n• Business Insider The 23 Most Influential LGBTQ+ People in Tech" - }, - "Laxmi Narayan Tripathi": { - "Known for": "Humans rights activist and founder, Astitva trust", - "About": "The first transgender individual to represent APAC in the UN task meeting in 2008, representative of APAC yet again at the 20th International AIDS Conference in Melbourne and recipient of the ‘Indian of the Year’ award in 2017, Lakshmi Narayan Tripathi is a transgender activist, and part of the team that led the charge to getting transgender recognized as a third gender in India by the Supreme Court in 2014.\n\nLakshmi was appointed as the President of the NGO DAI Welfare Society in 2002, the first registered and working organization for eunuchs in South Asia. By 2007 she founded her own organization, Astitiva that works towards the welfare, support and development of sexual minorities.\n\nWith the background of an abusive childhood and being bullied for being feminine, she stated in an interview, “I chose not to remember the prejudice,” adding, “Rather I think (about) the good things that have happened to me, and be a flamboyant rainbow.”", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/bd/Laxmi_Narayan_Tripathi_at_JLF_Melbourne_presented_by_Melbourne_Writers_Festival%2C_Federation_Square%2C_Melbourne_2017.jpg/220px-Laxmi_Narayan_Tripathi_at_JLF_Melbourne_presented_by_Melbourne_Writers_Festival%2C_Federation_Square%2C_Melbourne_2017.jpg", - "Born": "On 13th Dec 1978 at Thane", - "Awards": "• Awarded 'Indian of the Year 2017" - }, - "Tim Gill": { - "Known for": "Founder of Quark", - "About": "Tim Gill founded Quark Inc in 1981 and sold his stakes in Quark in 1999 in order to focus more on his interests in LGBT+ activism and philanthropy. He founded the pro-LGBT Gill Foundation in 1994, and since its inception it has invested more than $357 Mn in programs and non-profits around the country, substantially contributing towards many victories for LGBT community.\n\nGill married Scott Miller in 2009 and continues to be the largest donor for LGBT initiatives in America.\n\n“The LGBTQ movement has no Martin Luther King. We never have. And we probably never will,” Gill said. “So it’s not going to be grandiose gestures and big speeches and things like that that secure us equal opportunity. It will be the hard work of thousands and thousands of people over many, many years.”", - "url": "https://gillfoundation.org/wp-content/uploads/2014/09/tim-gill-20151.jpg", - "Born": "On October 18, 1953 at Hobart, Indiana", - "Awards": "• Gill was awarded the NOGLSTP GLBT Engineer of the Year Award in 2007." - } -} -- cgit v1.2.3 From 14cd6815c7c7033f482f55047515a9dc465d547c Mon Sep 17 00:00:00 2001 From: Janine vN Date: Sat, 4 Sep 2021 23:48:57 -0400 Subject: Move Hanukkah to Holidays folder Hannukah was previously in the Christmas folder, which was weird. This now moves it to its own folder under Holidays. --- bot/exts/christmas/hanukkah_embed.py | 113 --------------------------- bot/exts/holidays/hanukkah/__init__.py | 0 bot/exts/holidays/hanukkah/hanukkah_embed.py | 113 +++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 113 deletions(-) delete mode 100644 bot/exts/christmas/hanukkah_embed.py create mode 100644 bot/exts/holidays/hanukkah/__init__.py create mode 100644 bot/exts/holidays/hanukkah/hanukkah_embed.py (limited to 'bot') diff --git a/bot/exts/christmas/hanukkah_embed.py b/bot/exts/christmas/hanukkah_embed.py deleted file mode 100644 index 00125be3..00000000 --- a/bot/exts/christmas/hanukkah_embed.py +++ /dev/null @@ -1,113 +0,0 @@ -import datetime -import logging - -from discord import Embed -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Colours, Month -from bot.utils.decorators import in_month - -log = logging.getLogger(__name__) - -HEBCAL_URL = ( - "https://www.hebcal.com/hebcal/?v=1&cfg=json&maj=on&min=on&mod=on&nx=on&" - "year=now&month=x&ss=on&mf=on&c=on&geo=geoname&geonameid=3448439&m=50&s=on" -) - - -class HanukkahEmbed(commands.Cog): - """A cog that returns information about Hanukkah festival.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.hanukkah_days = [] - self.hanukkah_months = [] - self.hanukkah_years = [] - - async def get_hanukkah_dates(self) -> list[str]: - """Gets the dates for hanukkah festival.""" - hanukkah_dates = [] - async with self.bot.http_session.get(HEBCAL_URL) as response: - json_data = await response.json() - festivals = json_data["items"] - for festival in festivals: - if festival["title"].startswith("Chanukah"): - date = festival["date"] - hanukkah_dates.append(date) - return hanukkah_dates - - @in_month(Month.DECEMBER) - @commands.command(name="hanukkah", aliases=("chanukah",)) - async def hanukkah_festival(self, ctx: commands.Context) -> None: - """Tells you about the Hanukkah Festivaltime of festival, festival day, etc).""" - hanukkah_dates = await self.get_hanukkah_dates() - self.hanukkah_dates_split(hanukkah_dates) - hanukkah_start_day = int(self.hanukkah_days[0]) - hanukkah_start_month = int(self.hanukkah_months[0]) - hanukkah_start_year = int(self.hanukkah_years[0]) - hanukkah_end_day = int(self.hanukkah_days[8]) - hanukkah_end_month = int(self.hanukkah_months[8]) - hanukkah_end_year = int(self.hanukkah_years[8]) - - hanukkah_start = datetime.date(hanukkah_start_year, hanukkah_start_month, hanukkah_start_day) - hanukkah_end = datetime.date(hanukkah_end_year, hanukkah_end_month, hanukkah_end_day) - today = datetime.date.today() - # today = datetime.date(2019, 12, 24) (for testing) - day = str(today.day) - month = str(today.month) - year = str(today.year) - embed = Embed(title="Hanukkah", colour=Colours.blue) - if day in self.hanukkah_days and month in self.hanukkah_months and year in self.hanukkah_years: - if int(day) == hanukkah_start_day: - now = datetime.datetime.utcnow() - hours = now.hour + 4 # using only hours - hanukkah_start_hour = 18 - if hours < hanukkah_start_hour: - embed.description = ( - "Hanukkah hasnt started yet, " - f"it will start in about {hanukkah_start_hour - hours} hour/s." - ) - await ctx.send(embed=embed) - return - elif hours > hanukkah_start_hour: - embed.description = ( - "It is the starting day of Hanukkah! " - f"Its been {hours - hanukkah_start_hour} hours hanukkah started!" - ) - await ctx.send(embed=embed) - return - festival_day = self.hanukkah_days.index(day) - number_suffixes = ["st", "nd", "rd", "th"] - suffix = number_suffixes[festival_day - 1 if festival_day <= 3 else 3] - message = ":menorah:" * festival_day - embed.description = f"It is the {festival_day}{suffix} day of Hanukkah!\n{message}" - await ctx.send(embed=embed) - else: - if today < hanukkah_start: - festival_starting_month = hanukkah_start.strftime("%B") - embed.description = ( - f"Hanukkah has not started yet. " - f"Hanukkah will start at sundown on {hanukkah_start_day}th " - f"of {festival_starting_month}." - ) - else: - festival_end_month = hanukkah_end.strftime("%B") - embed.description = ( - f"Looks like you missed Hanukkah!" - f"Hanukkah ended on {hanukkah_end_day}th of {festival_end_month}." - ) - - await ctx.send(embed=embed) - - def hanukkah_dates_split(self, hanukkah_dates: list[str]) -> None: - """We are splitting the dates for hanukkah into days, months and years.""" - for date in hanukkah_dates: - self.hanukkah_days.append(date[8:10]) - self.hanukkah_months.append(date[5:7]) - self.hanukkah_years.append(date[0:4]) - - -def setup(bot: Bot) -> None: - """Load the Hanukkah Embed Cog.""" - bot.add_cog(HanukkahEmbed(bot)) diff --git a/bot/exts/holidays/hanukkah/__init__.py b/bot/exts/holidays/hanukkah/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bot/exts/holidays/hanukkah/hanukkah_embed.py b/bot/exts/holidays/hanukkah/hanukkah_embed.py new file mode 100644 index 00000000..00125be3 --- /dev/null +++ b/bot/exts/holidays/hanukkah/hanukkah_embed.py @@ -0,0 +1,113 @@ +import datetime +import logging + +from discord import Embed +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Colours, Month +from bot.utils.decorators import in_month + +log = logging.getLogger(__name__) + +HEBCAL_URL = ( + "https://www.hebcal.com/hebcal/?v=1&cfg=json&maj=on&min=on&mod=on&nx=on&" + "year=now&month=x&ss=on&mf=on&c=on&geo=geoname&geonameid=3448439&m=50&s=on" +) + + +class HanukkahEmbed(commands.Cog): + """A cog that returns information about Hanukkah festival.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.hanukkah_days = [] + self.hanukkah_months = [] + self.hanukkah_years = [] + + async def get_hanukkah_dates(self) -> list[str]: + """Gets the dates for hanukkah festival.""" + hanukkah_dates = [] + async with self.bot.http_session.get(HEBCAL_URL) as response: + json_data = await response.json() + festivals = json_data["items"] + for festival in festivals: + if festival["title"].startswith("Chanukah"): + date = festival["date"] + hanukkah_dates.append(date) + return hanukkah_dates + + @in_month(Month.DECEMBER) + @commands.command(name="hanukkah", aliases=("chanukah",)) + async def hanukkah_festival(self, ctx: commands.Context) -> None: + """Tells you about the Hanukkah Festivaltime of festival, festival day, etc).""" + hanukkah_dates = await self.get_hanukkah_dates() + self.hanukkah_dates_split(hanukkah_dates) + hanukkah_start_day = int(self.hanukkah_days[0]) + hanukkah_start_month = int(self.hanukkah_months[0]) + hanukkah_start_year = int(self.hanukkah_years[0]) + hanukkah_end_day = int(self.hanukkah_days[8]) + hanukkah_end_month = int(self.hanukkah_months[8]) + hanukkah_end_year = int(self.hanukkah_years[8]) + + hanukkah_start = datetime.date(hanukkah_start_year, hanukkah_start_month, hanukkah_start_day) + hanukkah_end = datetime.date(hanukkah_end_year, hanukkah_end_month, hanukkah_end_day) + today = datetime.date.today() + # today = datetime.date(2019, 12, 24) (for testing) + day = str(today.day) + month = str(today.month) + year = str(today.year) + embed = Embed(title="Hanukkah", colour=Colours.blue) + if day in self.hanukkah_days and month in self.hanukkah_months and year in self.hanukkah_years: + if int(day) == hanukkah_start_day: + now = datetime.datetime.utcnow() + hours = now.hour + 4 # using only hours + hanukkah_start_hour = 18 + if hours < hanukkah_start_hour: + embed.description = ( + "Hanukkah hasnt started yet, " + f"it will start in about {hanukkah_start_hour - hours} hour/s." + ) + await ctx.send(embed=embed) + return + elif hours > hanukkah_start_hour: + embed.description = ( + "It is the starting day of Hanukkah! " + f"Its been {hours - hanukkah_start_hour} hours hanukkah started!" + ) + await ctx.send(embed=embed) + return + festival_day = self.hanukkah_days.index(day) + number_suffixes = ["st", "nd", "rd", "th"] + suffix = number_suffixes[festival_day - 1 if festival_day <= 3 else 3] + message = ":menorah:" * festival_day + embed.description = f"It is the {festival_day}{suffix} day of Hanukkah!\n{message}" + await ctx.send(embed=embed) + else: + if today < hanukkah_start: + festival_starting_month = hanukkah_start.strftime("%B") + embed.description = ( + f"Hanukkah has not started yet. " + f"Hanukkah will start at sundown on {hanukkah_start_day}th " + f"of {festival_starting_month}." + ) + else: + festival_end_month = hanukkah_end.strftime("%B") + embed.description = ( + f"Looks like you missed Hanukkah!" + f"Hanukkah ended on {hanukkah_end_day}th of {festival_end_month}." + ) + + await ctx.send(embed=embed) + + def hanukkah_dates_split(self, hanukkah_dates: list[str]) -> None: + """We are splitting the dates for hanukkah into days, months and years.""" + for date in hanukkah_dates: + self.hanukkah_days.append(date[8:10]) + self.hanukkah_months.append(date[5:7]) + self.hanukkah_years.append(date[0:4]) + + +def setup(bot: Bot) -> None: + """Load the Hanukkah Embed Cog.""" + bot.add_cog(HanukkahEmbed(bot)) -- cgit v1.2.3 From 34ecc9e688c6a9a04ef54c2584fe814890d3979a Mon Sep 17 00:00:00 2001 From: Janine vN Date: Sat, 4 Sep 2021 23:55:44 -0400 Subject: Update paths to new resource links Additionally, this commit fixes an error with the pridepfp command. The avatar image now uses discord.py's v.20 avatar.url instead of avatar_url --- bot/exts/avatar_modification/__init__.py | 0 bot/exts/avatar_modification/_effects.py | 296 ++++++++++++++++ bot/exts/avatar_modification/avatar_modify.py | 372 +++++++++++++++++++++ bot/exts/evergreen/avatar_modification/__init__.py | 0 bot/exts/evergreen/avatar_modification/_effects.py | 296 ---------------- .../evergreen/avatar_modification/avatar_modify.py | 372 --------------------- 6 files changed, 668 insertions(+), 668 deletions(-) create mode 100644 bot/exts/avatar_modification/__init__.py create mode 100644 bot/exts/avatar_modification/_effects.py create mode 100644 bot/exts/avatar_modification/avatar_modify.py delete mode 100644 bot/exts/evergreen/avatar_modification/__init__.py delete mode 100644 bot/exts/evergreen/avatar_modification/_effects.py delete mode 100644 bot/exts/evergreen/avatar_modification/avatar_modify.py (limited to 'bot') diff --git a/bot/exts/avatar_modification/__init__.py b/bot/exts/avatar_modification/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bot/exts/avatar_modification/_effects.py b/bot/exts/avatar_modification/_effects.py new file mode 100644 index 00000000..f1c2e6d1 --- /dev/null +++ b/bot/exts/avatar_modification/_effects.py @@ -0,0 +1,296 @@ +import math +import random +from io import BytesIO +from pathlib import Path +from typing import Callable, Optional + +import discord +from PIL import Image, ImageDraw, ImageOps + +from bot.constants import Colours + + +class PfpEffects: + """ + Implements various image modifying effects, for the PfpModify cog. + + All of these functions are slow, and blocking, so they should be ran in executors. + """ + + @staticmethod + def apply_effect(image_bytes: bytes, effect: Callable, filename: str, *args) -> discord.File: + """Applies the given effect to the image passed to it.""" + im = Image.open(BytesIO(image_bytes)) + im = im.convert("RGBA") + im = im.resize((1024, 1024)) + im = effect(im, *args) + + bufferedio = BytesIO() + im.save(bufferedio, format="PNG") + bufferedio.seek(0) + + return discord.File(bufferedio, filename=filename) + + @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]) -> 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.easter_like_colours, key=distance) + r2, g2, b2 = closest_colours[0] + r = (r1 + r2) // 2 + g = (g1 + g2) // 2 + b = (b1 + b2) // 2 + + return r, g, b + + @staticmethod + def crop_avatar_circle(avatar: Image.Image) -> Image.Image: + """This crops the avatar given 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.Image, px: int) -> Image.Image: + """This crops the given 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 + + @staticmethod + def pridify_effect(image: Image.Image, pixels: int, flag: str) -> Image.Image: + """Applies the given pride effect to the given image.""" + image = PfpEffects.crop_avatar_circle(image) + + ring = Image.open(Path(f"bot/resources/holidays/pride/flags/{flag}.png")).resize((1024, 1024)) + ring = ring.convert("RGBA") + ring = PfpEffects.crop_ring(ring, pixels) + + image.alpha_composite(ring, (0, 0)) + return image + + @staticmethod + def eight_bitify_effect(image: Image.Image) -> Image.Image: + """ + Applies the 8bit effect to the given image. + + This is done by reducing the image to 32x32 and then back up to 1024x1024. + We then quantize the image before returning too. + """ + image = image.resize((32, 32), resample=Image.NEAREST) + image = image.resize((1024, 1024), resample=Image.NEAREST) + return image.quantize() + + @staticmethod + def flip_effect(image: Image.Image) -> Image.Image: + """ + Flips the image horizontally. + + This is done by just using ImageOps.mirror(). + """ + image = ImageOps.mirror(image) + + return image + + @staticmethod + def easterify_effect(image: Image.Image, overlay_image: Optional[Image.Image] = None) -> Image.Image: + """ + Applies the easter effect to the given image. + + This is done by getting the closest "easter" colour to each pixel and changing the colour + to the half-way RGB value. + + We also then add an overlay image on top in middle right, a chocolate bunny by default. + """ + if overlay_image: + ratio = 64 / overlay_image.height + overlay_image = overlay_image.resize(( + round(overlay_image.width * ratio), + round(overlay_image.height * ratio) + )) + overlay_image = overlay_image.convert("RGBA") + else: + overlay_image = Image.open(Path("bot/resources/holidays/easter/chocolate_bunny.png")) + + alpha = image.getchannel("A").getdata() + image = image.convert("RGB") + image = ImageOps.posterize(image, 6) + + data = image.getdata() + data_set = set(data) + easterified_data_set = {} + + for x in data_set: + easterified_data_set[x] = PfpEffects.closest(x) + new_pixel_data = [ + (*easterified_data_set[x], alpha[i]) + if x in easterified_data_set else x + for i, x in enumerate(data) + ] + + im = Image.new("RGBA", image.size) + im.putdata(new_pixel_data) + im.alpha_composite( + overlay_image, + (im.width - overlay_image.width, (im.height - overlay_image.height) // 2) + ) + return im + + @staticmethod + def split_image(img: Image.Image, squares: int) -> list: + """ + Split an image into a selection of squares, specified by the squares argument. + + Explanation: + + 1. It gets the width and the height of the Image passed to the function. + + 2. It gets the root of a number of squares (number of squares) passed, which is called xy. Reason: if let's say + 25 squares (number of squares) were passed, that is the total squares (split pieces) that the image is supposed + to be split into. As it is known, a 2D shape has a height and a width, and in this case the program thinks of it + as rows and columns. Rows multiplied by columns is equal to the passed squares (number of squares). To get rows + and columns, since in this case, each square (split piece) is identical, rows are equal to columns and the + program treats the image as a square-shaped, it gets the root out of the squares (number of squares) passed. + + 3. Now width and height are both of the original Image, Discord PFP, so when it comes to forming the squares, + the program divides the original image's height and width by the xy. In a case of 25 squares (number of squares) + passed, xy would be 5, so if an image was 250x300, x_frac would be 50 and y_frac - 60. Note: + x_frac stands for a fracture of width. The reason it's called that is because it is shorter to use x for width + in mind and then it's just half of the word fracture, same applies to y_frac, just height instead of width. + x_frac and y_frac are width and height of a single square (split piece). + + 4. With left, top, right, bottom, = 0, 0, x_frac, y_frac, the program sets these variables to create the initial + square (split piece). Explanation: all of these 4 variables start at the top left corner of the Image, by adding + value to right and bottom, it's creating the initial square (split piece). + + 5. In the for loop, it keeps adding those squares (split pieces) in a row and once (index + 1) % xy == 0 is + True, it adds to top and bottom to lower them and reset right and left to recreate the initial space between + them, forming a square (split piece), it also adds the newly created square (split piece) into the new_imgs list + where it stores them. The program keeps repeating this process till all 25 squares get added to the list. + + 6. It returns new_imgs, a list of squares (split pieces). + """ + width, heigth = img.size + + xy = math.sqrt(squares) + + x_frac = width // xy + y_frac = heigth // xy + + left, top, right, bottom, = 0, 0, x_frac, y_frac + + new_imgs = [] + + for index in range(squares): + new_img = img.crop((left, top, right, bottom)) + new_imgs.append(new_img) + + if (index + 1) % xy == 0: + top += y_frac + bottom += y_frac + left = 0 + right = x_frac + else: + left += x_frac + right += x_frac + + return new_imgs + + @staticmethod + def join_images(images: list[Image.Image]) -> Image.Image: + """ + Stitches all the image squares into a new image. + + Explanation: + + 1. Shuffles the passed images to randomize the pieces. + + 2. The program gets a single square (split piece) out of the list and defines single_width as the square's width + and single_height as the square's height. + + 3. It gets the root of type integer of the number of images (split pieces) in the list and calls it multiplier. + Program then proceeds to calculate total height and width of the new image that it's creating using the same + multiplier. + + 4. The program then defines new_image as the image that it's creating, using the previously obtained total_width + and total_height. + + 5. Now it defines width_multiplier as well as height with values of 0. These will be used to correctly position + squares (split pieces) onto the new_image canvas. + + 6. Similar to how in the split_image function, the program gets the root of number of images in the list. + In split_image function, it was the passed squares (number of squares) instead of a number of imgs in the + list that it got the square of here. + + 7. In the for loop, as it iterates, the program multiplies single_width by width_multiplier to correctly + position a square (split piece) width wise. It then proceeds to paste the newly positioned square (split piece) + onto the new_image. The program increases the width_multiplier by 1 every iteration so the image wouldn't get + pasted in the same spot and the positioning would move accordingly. It makes sure to increase the + width_multiplier before the check, which checks if the end of a row has been reached, - + (index + 1) % pieces == 0, so after it, if it was True, width_multiplier would have been reset to 0 (start of + the row). If the check returns True, the height gets increased by a single square's (split piece) height to + lower the positioning height wise and, as mentioned, the width_multiplier gets reset to 0 and width will + then be calculated from the start of the new row. The for loop finishes once all the squares (split pieces) were + positioned accordingly. + + 8. Finally, it returns the new_image, the randomized squares (split pieces) stitched back into the format of the + original image - user's PFP. + """ + random.shuffle(images) + single_img = images[0] + + single_wdith = single_img.size[0] + single_height = single_img.size[1] + + multiplier = int(math.sqrt(len(images))) + + total_width = multiplier * single_wdith + total_height = multiplier * single_height + + new_image = Image.new("RGBA", (total_width, total_height), (250, 250, 250)) + + width_multiplier = 0 + height = 0 + + squares = math.sqrt(len(images)) + + for index, image in enumerate(images): + width = single_wdith * width_multiplier + + new_image.paste(image, (width, height)) + + width_multiplier += 1 + + if (index + 1) % squares == 0: + width_multiplier = 0 + height += single_height + + return new_image + + @staticmethod + def mosaic_effect(image: Image.Image, squares: int) -> Image.Image: + """ + Applies a mosaic effect to the given image. + + The "squares" argument specifies the number of squares to split + the image into. This should be a square number. + """ + img_squares = PfpEffects.split_image(image, squares) + new_img = PfpEffects.join_images(img_squares) + + return new_img diff --git a/bot/exts/avatar_modification/avatar_modify.py b/bot/exts/avatar_modification/avatar_modify.py new file mode 100644 index 00000000..87eb05e6 --- /dev/null +++ b/bot/exts/avatar_modification/avatar_modify.py @@ -0,0 +1,372 @@ +import asyncio +import json +import logging +import math +import string +import unicodedata +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import Callable, Optional, TypeVar, Union + +import discord +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Colours, Emojis +from bot.exts.avatar_modification._effects import PfpEffects +from bot.utils.extensions import invoke_help_command +from bot.utils.halloween import spookifications + +log = logging.getLogger(__name__) + +_EXECUTOR = ThreadPoolExecutor(10) + +FILENAME_STRING = "{effect}_{author}.png" + +MAX_SQUARES = 10_000 + +T = TypeVar("T") + +GENDER_OPTIONS = json.loads(Path("bot/resources/holidays/pride/gender_options.json").read_text("utf8")) + + +async def in_executor(func: Callable[..., T], *args) -> T: + """ + Runs the given synchronous function `func` in an executor. + + This is useful for running slow, blocking code within async + functions, so that they don't block the bot. + """ + log.trace(f"Running {func.__name__} in an executor.") + loop = asyncio.get_event_loop() + return await loop.run_in_executor(_EXECUTOR, func, *args) + + +def file_safe_name(effect: str, display_name: str) -> str: + """Returns a file safe filename based on the given effect and display name.""" + valid_filename_chars = f"-_. {string.ascii_letters}{string.digits}" + + file_name = FILENAME_STRING.format(effect=effect, author=display_name) + + # Replace spaces + file_name = file_name.replace(" ", "_") + + # Normalize unicode characters + cleaned_filename = unicodedata.normalize("NFKD", file_name).encode("ASCII", "ignore").decode() + + # Remove invalid filename characters + cleaned_filename = "".join(c for c in cleaned_filename if c in valid_filename_chars) + return cleaned_filename + + +class AvatarModify(commands.Cog): + """Various commands for users to apply affects to their own avatars.""" + + def __init__(self, bot: Bot): + self.bot = bot + + async def _fetch_user(self, user_id: int) -> Optional[discord.User]: + """ + Fetches a user and handles errors. + + This helper function is required as the member cache doesn't always have the most up to date + profile picture. This can lead to errors if the image is deleted from the Discord CDN. + fetch_member can't be used due to the avatar url being part of the user object, and + some weird caching that D.py does + """ + try: + user = await self.bot.fetch_user(user_id) + except discord.errors.NotFound: + log.debug(f"User {user_id} could not be found.") + return None + except discord.HTTPException: + log.exception(f"Exception while trying to retrieve user {user_id} from Discord.") + return None + + return user + + @commands.group(aliases=("avatar_mod", "pfp_mod", "avatarmod", "pfpmod")) + async def avatar_modify(self, ctx: commands.Context) -> None: + """Groups all of the pfp modifying commands to allow a single concurrency limit.""" + if not ctx.invoked_subcommand: + await invoke_help_command(ctx) + + @avatar_modify.command(name="8bitify", root_aliases=("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(): + user = await self._fetch_user(ctx.author.id) + if not user: + await ctx.send(f"{Emojis.cross_mark} Could not get user info.") + return + + image_bytes = await user.display_avatar.replace(size=1024).read() + file_name = file_safe_name("eightbit_avatar", ctx.author.display_name) + + file = await in_executor( + PfpEffects.apply_effect, + image_bytes, + PfpEffects.eight_bitify_effect, + file_name + ) + + 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=f"attachment://{file_name}") + embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=user.display_avatar.url) + + await ctx.send(embed=embed, file=file) + + @avatar_modify.command(name="reverse", root_aliases=("reverse",)) + async def reverse(self, ctx: commands.Context, *, text: Optional[str]) -> None: + """ + Reverses the sent text. + + If no text is provided, the user's profile picture will be reversed. + """ + if text: + await ctx.send(f"> {text[::-1]}", allowed_mentions=discord.AllowedMentions.none()) + return + + async with ctx.typing(): + user = await self._fetch_user(ctx.author.id) + if not user: + await ctx.send(f"{Emojis.cross_mark} Could not get user info.") + return + + image_bytes = await user.display_avatar.replace(size=1024).read() + filename = file_safe_name("reverse_avatar", ctx.author.display_name) + + file = await in_executor( + PfpEffects.apply_effect, + image_bytes, + PfpEffects.flip_effect, + filename + ) + + embed = discord.Embed( + title="Your reversed avatar.", + description="Here is your reversed avatar. I think it is a spitting image of you." + ) + + embed.set_image(url=f"attachment://{filename}") + embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=user.display_avatar.url) + + await ctx.send(embed=embed, file=file) + + @avatar_modify.command(aliases=("easterify",), root_aliases=("easterify", "avatareasterify")) + 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(): + user = await self._fetch_user(ctx.author.id) + if not user: + await ctx.send(f"{Emojis.cross_mark} Could not get user info.") + return + + egg = None + 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. + await send_message(egg) + return + ctx.send = send_message # Reassigns ctx.send + + image_bytes = await user.display_avatar.replace(size=256).read() + file_name = file_safe_name("easterified_avatar", ctx.author.display_name) + + file = await in_executor( + PfpEffects.apply_effect, + image_bytes, + PfpEffects.easterify_effect, + file_name, + egg + ) + + embed = discord.Embed( + title="Your Lovely Easterified Avatar!", + description="Here is your lovely avatar, all bright and colourful\nwith Easter pastel colours. Enjoy :D" + ) + embed.set_image(url=f"attachment://{file_name}") + embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=user.display_avatar.url) + + await ctx.send(file=file, embed=embed) + + @staticmethod + async def send_pride_image( + ctx: commands.Context, + image_bytes: bytes, + pixels: int, + flag: str, + option: str + ) -> None: + """Gets and sends the image in an embed. Used by the pride commands.""" + async with ctx.typing(): + file_name = file_safe_name("pride_avatar", ctx.author.display_name) + + file = await in_executor( + PfpEffects.apply_effect, + image_bytes, + PfpEffects.pridify_effect, + file_name, + pixels, + flag + ) + + embed = discord.Embed( + title="Your Lovely Pride Avatar!", + description=f"Here is your lovely avatar, surrounded by\n a beautiful {option} flag. Enjoy :D" + ) + embed.set_image(url=f"attachment://{file_name}") + embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=ctx.author.avatar.url) + await ctx.send(file=file, embed=embed) + + @avatar_modify.group( + aliases=("avatarpride", "pridepfp", "prideprofile"), + root_aliases=("prideavatar", "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. + """ + option = option.lower() + pixels = max(0, min(512, pixels)) + flag = GENDER_OPTIONS.get(option) + if flag is None: + await ctx.send("I don't have that flag!") + return + + async with ctx.typing(): + user = await self._fetch_user(ctx.author.id) + if not user: + await ctx.send(f"{Emojis.cross_mark} Could not get user info.") + return + image_bytes = await user.display_avatar.replace(size=1024).read() + await self.send_pride_image(ctx, image_bytes, pixels, flag, option) + + @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(GENDER_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) + + @avatar_modify.command( + aliases=("savatar", "spookify"), + root_aliases=("spookyavatar", "spookify", "savatar"), + brief="Spookify an user's avatar." + ) + async def spookyavatar(self, ctx: commands.Context) -> None: + """This "spookifies" the user's avatar, with a random *spooky* effect.""" + user = await self._fetch_user(ctx.author.id) + if not user: + await ctx.send(f"{Emojis.cross_mark} Could not get user info.") + return + + async with ctx.typing(): + image_bytes = await user.display_avatar.replace(size=1024).read() + + file_name = file_safe_name("spooky_avatar", ctx.author.display_name) + + file = await in_executor( + PfpEffects.apply_effect, + image_bytes, + spookifications.get_random_effect, + file_name + ) + + embed = discord.Embed( + title="Is this you or am I just really paranoid?", + colour=Colours.soft_red + ) + embed.set_image(url=f"attachment://{file_name}") + embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=ctx.author.display_avatar.url) + + await ctx.send(file=file, embed=embed) + + @avatar_modify.command(name="mosaic", root_aliases=("mosaic",)) + async def mosaic_command(self, ctx: commands.Context, squares: int = 16) -> None: + """Splits your avatar into x squares, randomizes them and stitches them back into a new image!""" + async with ctx.typing(): + user = await self._fetch_user(ctx.author.id) + if not user: + await ctx.send(f"{Emojis.cross_mark} Could not get user info.") + return + + if not 1 <= squares <= MAX_SQUARES: + raise commands.BadArgument(f"Squares must be a positive number less than or equal to {MAX_SQUARES:,}.") + + sqrt = math.sqrt(squares) + + if not sqrt.is_integer(): + squares = math.ceil(sqrt) ** 2 # Get the next perfect square + + file_name = file_safe_name("mosaic_avatar", ctx.author.display_name) + + img_bytes = await user.display_avatar.replace(size=1024).read() + + file = await in_executor( + PfpEffects.apply_effect, + img_bytes, + PfpEffects.mosaic_effect, + file_name, + squares, + ) + + if squares == 1: + title = "Hooh... that was a lot of work" + description = "I present to you... Yourself!" + elif squares == MAX_SQUARES: + title = "Testing the limits I see..." + description = "What a masterpiece. :star:" + else: + title = "Your mosaic avatar" + description = f"Here is your avatar. I think it looks a bit *puzzling*\nMade with {squares} squares." + + embed = discord.Embed( + title=title, + description=description, + colour=Colours.blue + ) + + embed.set_image(url=f"attachment://{file_name}") + embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=user.display_avatar.url) + + await ctx.send(file=file, embed=embed) + + +def setup(bot: Bot) -> None: + """Load the AvatarModify cog.""" + bot.add_cog(AvatarModify(bot)) diff --git a/bot/exts/evergreen/avatar_modification/__init__.py b/bot/exts/evergreen/avatar_modification/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/bot/exts/evergreen/avatar_modification/_effects.py b/bot/exts/evergreen/avatar_modification/_effects.py deleted file mode 100644 index df741973..00000000 --- a/bot/exts/evergreen/avatar_modification/_effects.py +++ /dev/null @@ -1,296 +0,0 @@ -import math -import random -from io import BytesIO -from pathlib import Path -from typing import Callable, Optional - -import discord -from PIL import Image, ImageDraw, ImageOps - -from bot.constants import Colours - - -class PfpEffects: - """ - Implements various image modifying effects, for the PfpModify cog. - - All of these functions are slow, and blocking, so they should be ran in executors. - """ - - @staticmethod - def apply_effect(image_bytes: bytes, effect: Callable, filename: str, *args) -> discord.File: - """Applies the given effect to the image passed to it.""" - im = Image.open(BytesIO(image_bytes)) - im = im.convert("RGBA") - im = im.resize((1024, 1024)) - im = effect(im, *args) - - bufferedio = BytesIO() - im.save(bufferedio, format="PNG") - bufferedio.seek(0) - - return discord.File(bufferedio, filename=filename) - - @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]) -> 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.easter_like_colours, key=distance) - r2, g2, b2 = closest_colours[0] - r = (r1 + r2) // 2 - g = (g1 + g2) // 2 - b = (b1 + b2) // 2 - - return r, g, b - - @staticmethod - def crop_avatar_circle(avatar: Image.Image) -> Image.Image: - """This crops the avatar given 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.Image, px: int) -> Image.Image: - """This crops the given 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 - - @staticmethod - def pridify_effect(image: Image.Image, pixels: int, flag: str) -> Image.Image: - """Applies the given pride effect to the given image.""" - image = PfpEffects.crop_avatar_circle(image) - - ring = Image.open(Path(f"bot/resources/pride/flags/{flag}.png")).resize((1024, 1024)) - ring = ring.convert("RGBA") - ring = PfpEffects.crop_ring(ring, pixels) - - image.alpha_composite(ring, (0, 0)) - return image - - @staticmethod - def eight_bitify_effect(image: Image.Image) -> Image.Image: - """ - Applies the 8bit effect to the given image. - - This is done by reducing the image to 32x32 and then back up to 1024x1024. - We then quantize the image before returning too. - """ - image = image.resize((32, 32), resample=Image.NEAREST) - image = image.resize((1024, 1024), resample=Image.NEAREST) - return image.quantize() - - @staticmethod - def flip_effect(image: Image.Image) -> Image.Image: - """ - Flips the image horizontally. - - This is done by just using ImageOps.mirror(). - """ - image = ImageOps.mirror(image) - - return image - - @staticmethod - def easterify_effect(image: Image.Image, overlay_image: Optional[Image.Image] = None) -> Image.Image: - """ - Applies the easter effect to the given image. - - This is done by getting the closest "easter" colour to each pixel and changing the colour - to the half-way RGB value. - - We also then add an overlay image on top in middle right, a chocolate bunny by default. - """ - if overlay_image: - ratio = 64 / overlay_image.height - overlay_image = overlay_image.resize(( - round(overlay_image.width * ratio), - round(overlay_image.height * ratio) - )) - overlay_image = overlay_image.convert("RGBA") - else: - overlay_image = Image.open(Path("bot/resources/easter/chocolate_bunny.png")) - - alpha = image.getchannel("A").getdata() - image = image.convert("RGB") - image = ImageOps.posterize(image, 6) - - data = image.getdata() - data_set = set(data) - easterified_data_set = {} - - for x in data_set: - easterified_data_set[x] = PfpEffects.closest(x) - new_pixel_data = [ - (*easterified_data_set[x], alpha[i]) - if x in easterified_data_set else x - for i, x in enumerate(data) - ] - - im = Image.new("RGBA", image.size) - im.putdata(new_pixel_data) - im.alpha_composite( - overlay_image, - (im.width - overlay_image.width, (im.height - overlay_image.height) // 2) - ) - return im - - @staticmethod - def split_image(img: Image.Image, squares: int) -> list: - """ - Split an image into a selection of squares, specified by the squares argument. - - Explanation: - - 1. It gets the width and the height of the Image passed to the function. - - 2. It gets the root of a number of squares (number of squares) passed, which is called xy. Reason: if let's say - 25 squares (number of squares) were passed, that is the total squares (split pieces) that the image is supposed - to be split into. As it is known, a 2D shape has a height and a width, and in this case the program thinks of it - as rows and columns. Rows multiplied by columns is equal to the passed squares (number of squares). To get rows - and columns, since in this case, each square (split piece) is identical, rows are equal to columns and the - program treats the image as a square-shaped, it gets the root out of the squares (number of squares) passed. - - 3. Now width and height are both of the original Image, Discord PFP, so when it comes to forming the squares, - the program divides the original image's height and width by the xy. In a case of 25 squares (number of squares) - passed, xy would be 5, so if an image was 250x300, x_frac would be 50 and y_frac - 60. Note: - x_frac stands for a fracture of width. The reason it's called that is because it is shorter to use x for width - in mind and then it's just half of the word fracture, same applies to y_frac, just height instead of width. - x_frac and y_frac are width and height of a single square (split piece). - - 4. With left, top, right, bottom, = 0, 0, x_frac, y_frac, the program sets these variables to create the initial - square (split piece). Explanation: all of these 4 variables start at the top left corner of the Image, by adding - value to right and bottom, it's creating the initial square (split piece). - - 5. In the for loop, it keeps adding those squares (split pieces) in a row and once (index + 1) % xy == 0 is - True, it adds to top and bottom to lower them and reset right and left to recreate the initial space between - them, forming a square (split piece), it also adds the newly created square (split piece) into the new_imgs list - where it stores them. The program keeps repeating this process till all 25 squares get added to the list. - - 6. It returns new_imgs, a list of squares (split pieces). - """ - width, heigth = img.size - - xy = math.sqrt(squares) - - x_frac = width // xy - y_frac = heigth // xy - - left, top, right, bottom, = 0, 0, x_frac, y_frac - - new_imgs = [] - - for index in range(squares): - new_img = img.crop((left, top, right, bottom)) - new_imgs.append(new_img) - - if (index + 1) % xy == 0: - top += y_frac - bottom += y_frac - left = 0 - right = x_frac - else: - left += x_frac - right += x_frac - - return new_imgs - - @staticmethod - def join_images(images: list[Image.Image]) -> Image.Image: - """ - Stitches all the image squares into a new image. - - Explanation: - - 1. Shuffles the passed images to randomize the pieces. - - 2. The program gets a single square (split piece) out of the list and defines single_width as the square's width - and single_height as the square's height. - - 3. It gets the root of type integer of the number of images (split pieces) in the list and calls it multiplier. - Program then proceeds to calculate total height and width of the new image that it's creating using the same - multiplier. - - 4. The program then defines new_image as the image that it's creating, using the previously obtained total_width - and total_height. - - 5. Now it defines width_multiplier as well as height with values of 0. These will be used to correctly position - squares (split pieces) onto the new_image canvas. - - 6. Similar to how in the split_image function, the program gets the root of number of images in the list. - In split_image function, it was the passed squares (number of squares) instead of a number of imgs in the - list that it got the square of here. - - 7. In the for loop, as it iterates, the program multiplies single_width by width_multiplier to correctly - position a square (split piece) width wise. It then proceeds to paste the newly positioned square (split piece) - onto the new_image. The program increases the width_multiplier by 1 every iteration so the image wouldn't get - pasted in the same spot and the positioning would move accordingly. It makes sure to increase the - width_multiplier before the check, which checks if the end of a row has been reached, - - (index + 1) % pieces == 0, so after it, if it was True, width_multiplier would have been reset to 0 (start of - the row). If the check returns True, the height gets increased by a single square's (split piece) height to - lower the positioning height wise and, as mentioned, the width_multiplier gets reset to 0 and width will - then be calculated from the start of the new row. The for loop finishes once all the squares (split pieces) were - positioned accordingly. - - 8. Finally, it returns the new_image, the randomized squares (split pieces) stitched back into the format of the - original image - user's PFP. - """ - random.shuffle(images) - single_img = images[0] - - single_wdith = single_img.size[0] - single_height = single_img.size[1] - - multiplier = int(math.sqrt(len(images))) - - total_width = multiplier * single_wdith - total_height = multiplier * single_height - - new_image = Image.new("RGBA", (total_width, total_height), (250, 250, 250)) - - width_multiplier = 0 - height = 0 - - squares = math.sqrt(len(images)) - - for index, image in enumerate(images): - width = single_wdith * width_multiplier - - new_image.paste(image, (width, height)) - - width_multiplier += 1 - - if (index + 1) % squares == 0: - width_multiplier = 0 - height += single_height - - return new_image - - @staticmethod - def mosaic_effect(image: Image.Image, squares: int) -> Image.Image: - """ - Applies a mosaic effect to the given image. - - The "squares" argument specifies the number of squares to split - the image into. This should be a square number. - """ - img_squares = PfpEffects.split_image(image, squares) - new_img = PfpEffects.join_images(img_squares) - - return new_img diff --git a/bot/exts/evergreen/avatar_modification/avatar_modify.py b/bot/exts/evergreen/avatar_modification/avatar_modify.py deleted file mode 100644 index 18202902..00000000 --- a/bot/exts/evergreen/avatar_modification/avatar_modify.py +++ /dev/null @@ -1,372 +0,0 @@ -import asyncio -import json -import logging -import math -import string -import unicodedata -from concurrent.futures import ThreadPoolExecutor -from pathlib import Path -from typing import Callable, Optional, TypeVar, Union - -import discord -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Colours, Emojis -from bot.exts.evergreen.avatar_modification._effects import PfpEffects -from bot.utils.extensions import invoke_help_command -from bot.utils.halloween import spookifications - -log = logging.getLogger(__name__) - -_EXECUTOR = ThreadPoolExecutor(10) - -FILENAME_STRING = "{effect}_{author}.png" - -MAX_SQUARES = 10_000 - -T = TypeVar("T") - -GENDER_OPTIONS = json.loads(Path("bot/resources/pride/gender_options.json").read_text("utf8")) - - -async def in_executor(func: Callable[..., T], *args) -> T: - """ - Runs the given synchronous function `func` in an executor. - - This is useful for running slow, blocking code within async - functions, so that they don't block the bot. - """ - log.trace(f"Running {func.__name__} in an executor.") - loop = asyncio.get_event_loop() - return await loop.run_in_executor(_EXECUTOR, func, *args) - - -def file_safe_name(effect: str, display_name: str) -> str: - """Returns a file safe filename based on the given effect and display name.""" - valid_filename_chars = f"-_. {string.ascii_letters}{string.digits}" - - file_name = FILENAME_STRING.format(effect=effect, author=display_name) - - # Replace spaces - file_name = file_name.replace(" ", "_") - - # Normalize unicode characters - cleaned_filename = unicodedata.normalize("NFKD", file_name).encode("ASCII", "ignore").decode() - - # Remove invalid filename characters - cleaned_filename = "".join(c for c in cleaned_filename if c in valid_filename_chars) - return cleaned_filename - - -class AvatarModify(commands.Cog): - """Various commands for users to apply affects to their own avatars.""" - - def __init__(self, bot: Bot): - self.bot = bot - - async def _fetch_user(self, user_id: int) -> Optional[discord.User]: - """ - Fetches a user and handles errors. - - This helper function is required as the member cache doesn't always have the most up to date - profile picture. This can lead to errors if the image is deleted from the Discord CDN. - fetch_member can't be used due to the avatar url being part of the user object, and - some weird caching that D.py does - """ - try: - user = await self.bot.fetch_user(user_id) - except discord.errors.NotFound: - log.debug(f"User {user_id} could not be found.") - return None - except discord.HTTPException: - log.exception(f"Exception while trying to retrieve user {user_id} from Discord.") - return None - - return user - - @commands.group(aliases=("avatar_mod", "pfp_mod", "avatarmod", "pfpmod")) - async def avatar_modify(self, ctx: commands.Context) -> None: - """Groups all of the pfp modifying commands to allow a single concurrency limit.""" - if not ctx.invoked_subcommand: - await invoke_help_command(ctx) - - @avatar_modify.command(name="8bitify", root_aliases=("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(): - user = await self._fetch_user(ctx.author.id) - if not user: - await ctx.send(f"{Emojis.cross_mark} Could not get user info.") - return - - image_bytes = await user.display_avatar.replace(size=1024).read() - file_name = file_safe_name("eightbit_avatar", ctx.author.display_name) - - file = await in_executor( - PfpEffects.apply_effect, - image_bytes, - PfpEffects.eight_bitify_effect, - file_name - ) - - 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=f"attachment://{file_name}") - embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=user.display_avatar.url) - - await ctx.send(embed=embed, file=file) - - @avatar_modify.command(name="reverse", root_aliases=("reverse",)) - async def reverse(self, ctx: commands.Context, *, text: Optional[str]) -> None: - """ - Reverses the sent text. - - If no text is provided, the user's profile picture will be reversed. - """ - if text: - await ctx.send(f"> {text[::-1]}", allowed_mentions=discord.AllowedMentions.none()) - return - - async with ctx.typing(): - user = await self._fetch_user(ctx.author.id) - if not user: - await ctx.send(f"{Emojis.cross_mark} Could not get user info.") - return - - image_bytes = await user.display_avatar.replace(size=1024).read() - filename = file_safe_name("reverse_avatar", ctx.author.display_name) - - file = await in_executor( - PfpEffects.apply_effect, - image_bytes, - PfpEffects.flip_effect, - filename - ) - - embed = discord.Embed( - title="Your reversed avatar.", - description="Here is your reversed avatar. I think it is a spitting image of you." - ) - - embed.set_image(url=f"attachment://{filename}") - embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=user.display_avatar.url) - - await ctx.send(embed=embed, file=file) - - @avatar_modify.command(aliases=("easterify",), root_aliases=("easterify", "avatareasterify")) - 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(): - user = await self._fetch_user(ctx.author.id) - if not user: - await ctx.send(f"{Emojis.cross_mark} Could not get user info.") - return - - egg = None - 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. - await send_message(egg) - return - ctx.send = send_message # Reassigns ctx.send - - image_bytes = await user.display_avatar.replace(size=256).read() - file_name = file_safe_name("easterified_avatar", ctx.author.display_name) - - file = await in_executor( - PfpEffects.apply_effect, - image_bytes, - PfpEffects.easterify_effect, - file_name, - egg - ) - - embed = discord.Embed( - title="Your Lovely Easterified Avatar!", - description="Here is your lovely avatar, all bright and colourful\nwith Easter pastel colours. Enjoy :D" - ) - embed.set_image(url=f"attachment://{file_name}") - embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=user.display_avatar.url) - - await ctx.send(file=file, embed=embed) - - @staticmethod - async def send_pride_image( - ctx: commands.Context, - image_bytes: bytes, - pixels: int, - flag: str, - option: str - ) -> None: - """Gets and sends the image in an embed. Used by the pride commands.""" - async with ctx.typing(): - file_name = file_safe_name("pride_avatar", ctx.author.display_name) - - file = await in_executor( - PfpEffects.apply_effect, - image_bytes, - PfpEffects.pridify_effect, - file_name, - pixels, - flag - ) - - embed = discord.Embed( - title="Your Lovely Pride Avatar!", - description=f"Here is your lovely avatar, surrounded by\n a beautiful {option} flag. Enjoy :D" - ) - embed.set_image(url=f"attachment://{file_name}") - embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=ctx.author.avatar_url) - await ctx.send(file=file, embed=embed) - - @avatar_modify.group( - aliases=("avatarpride", "pridepfp", "prideprofile"), - root_aliases=("prideavatar", "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. - """ - option = option.lower() - pixels = max(0, min(512, pixels)) - flag = GENDER_OPTIONS.get(option) - if flag is None: - await ctx.send("I don't have that flag!") - return - - async with ctx.typing(): - user = await self._fetch_user(ctx.author.id) - if not user: - await ctx.send(f"{Emojis.cross_mark} Could not get user info.") - return - image_bytes = await user.display_avatar.replace(size=1024).read() - await self.send_pride_image(ctx, image_bytes, pixels, flag, option) - - @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(GENDER_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) - - @avatar_modify.command( - aliases=("savatar", "spookify"), - root_aliases=("spookyavatar", "spookify", "savatar"), - brief="Spookify an user's avatar." - ) - async def spookyavatar(self, ctx: commands.Context) -> None: - """This "spookifies" the user's avatar, with a random *spooky* effect.""" - user = await self._fetch_user(ctx.author.id) - if not user: - await ctx.send(f"{Emojis.cross_mark} Could not get user info.") - return - - async with ctx.typing(): - image_bytes = await user.display_avatar.replace(size=1024).read() - - file_name = file_safe_name("spooky_avatar", ctx.author.display_name) - - file = await in_executor( - PfpEffects.apply_effect, - image_bytes, - spookifications.get_random_effect, - file_name - ) - - embed = discord.Embed( - title="Is this you or am I just really paranoid?", - colour=Colours.soft_red - ) - embed.set_image(url=f"attachment://{file_name}") - embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=ctx.author.display_avatar.url) - - await ctx.send(file=file, embed=embed) - - @avatar_modify.command(name="mosaic", root_aliases=("mosaic",)) - async def mosaic_command(self, ctx: commands.Context, squares: int = 16) -> None: - """Splits your avatar into x squares, randomizes them and stitches them back into a new image!""" - async with ctx.typing(): - user = await self._fetch_user(ctx.author.id) - if not user: - await ctx.send(f"{Emojis.cross_mark} Could not get user info.") - return - - if not 1 <= squares <= MAX_SQUARES: - raise commands.BadArgument(f"Squares must be a positive number less than or equal to {MAX_SQUARES:,}.") - - sqrt = math.sqrt(squares) - - if not sqrt.is_integer(): - squares = math.ceil(sqrt) ** 2 # Get the next perfect square - - file_name = file_safe_name("mosaic_avatar", ctx.author.display_name) - - img_bytes = await user.display_avatar.replace(size=1024).read() - - file = await in_executor( - PfpEffects.apply_effect, - img_bytes, - PfpEffects.mosaic_effect, - file_name, - squares, - ) - - if squares == 1: - title = "Hooh... that was a lot of work" - description = "I present to you... Yourself!" - elif squares == MAX_SQUARES: - title = "Testing the limits I see..." - description = "What a masterpiece. :star:" - else: - title = "Your mosaic avatar" - description = f"Here is your avatar. I think it looks a bit *puzzling*\nMade with {squares} squares." - - embed = discord.Embed( - title=title, - description=description, - colour=Colours.blue - ) - - embed.set_image(url=f"attachment://{file_name}") - embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=user.display_avatar.url) - - await ctx.send(file=file, embed=embed) - - -def setup(bot: Bot) -> None: - """Load the AvatarModify cog.""" - bot.add_cog(AvatarModify(bot)) -- cgit v1.2.3 From f0b5c14e1f59e5135f27a4966021f30c77d1fc7d Mon Sep 17 00:00:00 2001 From: Janine vN Date: Sun, 5 Sep 2021 00:06:04 -0400 Subject: Move internal eval and rename utils to core Part of this restructure involves splitting out the massive evergreen folder into a `fun` folder and then a `utilities` folder. To help with that we've rename the `util` folder to `core`. The core functions to run the bot have been moved into this folder. `.source`, `.ping`, and `.int e` have been moved into this folder. --- bot/exts/core/__init__.py | 0 bot/exts/core/error_handler.py | 182 +++++++++ bot/exts/core/extensions.py | 266 ++++++++++++ bot/exts/core/help.py | 562 ++++++++++++++++++++++++++ bot/exts/core/internal_eval/__init__.py | 10 + bot/exts/core/internal_eval/_helpers.py | 248 ++++++++++++ bot/exts/core/internal_eval/_internal_eval.py | 179 ++++++++ bot/exts/core/ping.py | 45 +++ bot/exts/core/source.py | 85 ++++ bot/exts/evergreen/error_handler.py | 182 --------- bot/exts/evergreen/help.py | 562 -------------------------- bot/exts/evergreen/ping.py | 45 --- bot/exts/evergreen/source.py | 85 ---- bot/exts/internal_eval/__init__.py | 10 - bot/exts/internal_eval/_helpers.py | 248 ------------ bot/exts/internal_eval/_internal_eval.py | 179 -------- bot/exts/utils/__init__.py | 0 bot/exts/utils/extensions.py | 266 ------------ 18 files changed, 1577 insertions(+), 1577 deletions(-) create mode 100644 bot/exts/core/__init__.py create mode 100644 bot/exts/core/error_handler.py create mode 100644 bot/exts/core/extensions.py create mode 100644 bot/exts/core/help.py create mode 100644 bot/exts/core/internal_eval/__init__.py create mode 100644 bot/exts/core/internal_eval/_helpers.py create mode 100644 bot/exts/core/internal_eval/_internal_eval.py create mode 100644 bot/exts/core/ping.py create mode 100644 bot/exts/core/source.py delete mode 100644 bot/exts/evergreen/error_handler.py delete mode 100644 bot/exts/evergreen/help.py delete mode 100644 bot/exts/evergreen/ping.py delete mode 100644 bot/exts/evergreen/source.py delete mode 100644 bot/exts/internal_eval/__init__.py delete mode 100644 bot/exts/internal_eval/_helpers.py delete mode 100644 bot/exts/internal_eval/_internal_eval.py delete mode 100644 bot/exts/utils/__init__.py delete mode 100644 bot/exts/utils/extensions.py (limited to 'bot') diff --git a/bot/exts/core/__init__.py b/bot/exts/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bot/exts/core/error_handler.py b/bot/exts/core/error_handler.py new file mode 100644 index 00000000..fd2123e7 --- /dev/null +++ b/bot/exts/core/error_handler.py @@ -0,0 +1,182 @@ +import difflib +import logging +import math +import random +from collections.abc import Iterable +from typing import Union + +from discord import Embed, Message +from discord.ext import commands +from sentry_sdk import push_scope + +from bot.bot import Bot +from bot.constants import Channels, Colours, ERROR_REPLIES, NEGATIVE_REPLIES, RedirectOutput +from bot.utils.decorators import InChannelCheckFailure, InMonthCheckFailure +from bot.utils.exceptions import APIError, UserNotPlayingError + +log = logging.getLogger(__name__) + + +QUESTION_MARK_ICON = "https://cdn.discordapp.com/emojis/512367613339369475.png" + + +class CommandErrorHandler(commands.Cog): + """A error handler for the PythonDiscord server.""" + + def __init__(self, bot: 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 raises an error.""" + if getattr(error, "handled", False): + logging.debug(f"Command {ctx.command} had its error already handled locally; ignoring.") + return + + parent_command = "" + if subctx := getattr(ctx, "subcontext", None): + parent_command = f"{ctx.command} " + ctx = subctx + + 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): + await self.send_command_suggestion(ctx, ctx.invoked_with) + 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) + usage = f"```\n{ctx.prefix}{parent_command}{ctx.command} {ctx.command.signature}\n```" + embed = self.error_embed( + f"Your input was invalid: {error}\n\nUsage:{usage}" + ) + 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( + f"This command can only be used in the server. Go to <#{Channels.community_bot_commands}> instead!", + 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```\n{ctx.prefix}{parent_command}{ctx.command} {ctx.command.signature}\n```" + ) + 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 + + if isinstance(error, UserNotPlayingError): + await ctx.send("Game not found.") + return + + if isinstance(error, APIError): + await ctx.send( + embed=self.error_embed( + f"There was an error when communicating with the {error.api}", + 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", ctx.message.jump_url) + + log.exception(f"Unhandled command error: {str(error)}", exc_info=error) + + async def send_command_suggestion(self, ctx: commands.Context, command_name: str) -> None: + """Sends user similar commands if any can be found.""" + raw_commands = [] + for cmd in self.bot.walk_commands(): + if not cmd.hidden: + raw_commands += (cmd.name, *cmd.aliases) + if similar_command_data := difflib.get_close_matches(command_name, raw_commands, 1): + similar_command_name = similar_command_data[0] + similar_command = self.bot.get_command(similar_command_name) + + if not similar_command: + return + + log_msg = "Cancelling attempt to suggest a command due to failed checks." + try: + if not await similar_command.can_run(ctx): + log.debug(log_msg) + return + except commands.errors.CommandError as cmd_error: + log.debug(log_msg) + await self.on_command_error(ctx, cmd_error) + return + + misspelled_content = ctx.message.content + e = Embed() + e.set_author(name="Did you mean:", icon_url=QUESTION_MARK_ICON) + e.description = misspelled_content.replace(command_name, similar_command_name, 1) + await ctx.send(embed=e, delete_after=RedirectOutput.delete_delay) + + +def setup(bot: Bot) -> None: + """Load the ErrorHandler cog.""" + bot.add_cog(CommandErrorHandler(bot)) diff --git a/bot/exts/core/extensions.py b/bot/exts/core/extensions.py new file mode 100644 index 00000000..424bacac --- /dev/null +++ b/bot/exts/core/extensions.py @@ -0,0 +1,266 @@ +import functools +import logging +from collections.abc import Mapping +from enum import Enum +from typing import Optional + +from discord import Colour, Embed +from discord.ext import commands +from discord.ext.commands import Context, group + +from bot import exts +from bot.bot import Bot +from bot.constants import Client, Emojis, MODERATION_ROLES, Roles +from bot.utils.checks import with_role_check +from bot.utils.extensions import EXTENSIONS, invoke_help_command, unqualify +from bot.utils.pagination import LinePaginator + +log = logging.getLogger(__name__) + + +UNLOAD_BLACKLIST = {f"{exts.__name__}.utils.extensions"} +BASE_PATH_LEN = len(exts.__name__.split(".")) + + +class Action(Enum): + """Represents an action to perform on an extension.""" + + # Need to be partial otherwise they are considered to be function definitions. + LOAD = functools.partial(Bot.load_extension) + UNLOAD = functools.partial(Bot.unload_extension) + RELOAD = functools.partial(Bot.reload_extension) + + +class Extension(commands.Converter): + """ + Fully qualify the name of an extension and ensure it exists. + + The * and ** values bypass this when used with the reload command. + """ + + async def convert(self, ctx: Context, argument: str) -> str: + """Fully qualify the name of an extension and ensure it exists.""" + # Special values to reload all extensions + if argument == "*" or argument == "**": + return argument + + argument = argument.lower() + + if argument in EXTENSIONS: + return argument + elif (qualified_arg := f"{exts.__name__}.{argument}") in EXTENSIONS: + return qualified_arg + + matches = [] + for ext in EXTENSIONS: + if argument == unqualify(ext): + matches.append(ext) + + if len(matches) > 1: + matches.sort() + names = "\n".join(matches) + raise commands.BadArgument( + f":x: `{argument}` is an ambiguous extension name. " + f"Please use one of the following fully-qualified names.```\n{names}\n```" + ) + elif matches: + return matches[0] + else: + raise commands.BadArgument(f":x: Could not find the extension `{argument}`.") + + +class Extensions(commands.Cog): + """Extension management commands.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @group(name="extensions", aliases=("ext", "exts", "c", "cogs"), invoke_without_command=True) + async def extensions_group(self, ctx: Context) -> None: + """Load, unload, reload, and list loaded extensions.""" + await invoke_help_command(ctx) + + @extensions_group.command(name="load", aliases=("l",)) + async def load_command(self, ctx: Context, *extensions: Extension) -> None: + r""" + Load extensions given their fully qualified or unqualified names. + + If '\*' or '\*\*' is given as the name, all unloaded extensions will be loaded. + """ # noqa: W605 + if not extensions: + await invoke_help_command(ctx) + return + + if "*" in extensions or "**" in extensions: + extensions = set(EXTENSIONS) - set(self.bot.extensions.keys()) + + msg = self.batch_manage(Action.LOAD, *extensions) + await ctx.send(msg) + + @extensions_group.command(name="unload", aliases=("ul",)) + async def unload_command(self, ctx: Context, *extensions: Extension) -> None: + r""" + Unload currently loaded extensions given their fully qualified or unqualified names. + + If '\*' or '\*\*' is given as the name, all loaded extensions will be unloaded. + """ # noqa: W605 + if not extensions: + await invoke_help_command(ctx) + return + + blacklisted = "\n".join(UNLOAD_BLACKLIST & set(extensions)) + + if blacklisted: + msg = f":x: The following extension(s) may not be unloaded:```\n{blacklisted}\n```" + else: + if "*" in extensions or "**" in extensions: + extensions = set(self.bot.extensions.keys()) - UNLOAD_BLACKLIST + + msg = self.batch_manage(Action.UNLOAD, *extensions) + + await ctx.send(msg) + + @extensions_group.command(name="reload", aliases=("r",), root_aliases=("reload",)) + async def reload_command(self, ctx: Context, *extensions: Extension) -> None: + r""" + Reload extensions given their fully qualified or unqualified names. + + If an extension fails to be reloaded, it will be rolled-back to the prior working state. + + If '\*' is given as the name, all currently loaded extensions will be reloaded. + If '\*\*' is given as the name, all extensions, including unloaded ones, will be reloaded. + """ # noqa: W605 + if not extensions: + await invoke_help_command(ctx) + return + + if "**" in extensions: + extensions = EXTENSIONS + elif "*" in extensions: + extensions = set(self.bot.extensions.keys()) | set(extensions) + extensions.remove("*") + + msg = self.batch_manage(Action.RELOAD, *extensions) + + await ctx.send(msg) + + @extensions_group.command(name="list", aliases=("all",)) + async def list_command(self, ctx: Context) -> None: + """ + Get a list of all extensions, including their loaded status. + + Grey indicates that the extension is unloaded. + Green indicates that the extension is currently loaded. + """ + embed = Embed(colour=Colour.blurple()) + embed.set_author( + name="Extensions List", + url=Client.github_bot_repo, + icon_url=str(self.bot.user.display_avatar.url) + ) + + lines = [] + categories = self.group_extension_statuses() + for category, extensions in sorted(categories.items()): + # Treat each category as a single line by concatenating everything. + # This ensures the paginator will not cut off a page in the middle of a category. + category = category.replace("_", " ").title() + extensions = "\n".join(sorted(extensions)) + lines.append(f"**{category}**\n{extensions}\n") + + log.debug(f"{ctx.author} requested a list of all cogs. Returning a paginated list.") + await LinePaginator.paginate(lines, ctx, embed, max_size=1200, empty=False) + + def group_extension_statuses(self) -> Mapping[str, str]: + """Return a mapping of extension names and statuses to their categories.""" + categories = {} + + for ext in EXTENSIONS: + if ext in self.bot.extensions: + status = Emojis.status_online + else: + status = Emojis.status_offline + + path = ext.split(".") + if len(path) > BASE_PATH_LEN + 1: + category = " - ".join(path[BASE_PATH_LEN:-1]) + else: + category = "uncategorised" + + categories.setdefault(category, []).append(f"{status} {path[-1]}") + + return categories + + def batch_manage(self, action: Action, *extensions: str) -> str: + """ + Apply an action to multiple extensions and return a message with the results. + + If only one extension is given, it is deferred to `manage()`. + """ + if len(extensions) == 1: + msg, _ = self.manage(action, extensions[0]) + return msg + + verb = action.name.lower() + failures = {} + + for extension in extensions: + _, error = self.manage(action, extension) + if error: + failures[extension] = error + + emoji = ":x:" if failures else ":ok_hand:" + msg = f"{emoji} {len(extensions) - len(failures)} / {len(extensions)} extensions {verb}ed." + + if failures: + failures = "\n".join(f"{ext}\n {err}" for ext, err in failures.items()) + msg += f"\nFailures:```\n{failures}\n```" + + log.debug(f"Batch {verb}ed extensions.") + + return msg + + def manage(self, action: Action, ext: str) -> tuple[str, Optional[str]]: + """Apply an action to an extension and return the status message and any error message.""" + verb = action.name.lower() + error_msg = None + + try: + action.value(self.bot, ext) + except (commands.ExtensionAlreadyLoaded, commands.ExtensionNotLoaded): + if action is Action.RELOAD: + # When reloading, just load the extension if it was not loaded. + return self.manage(Action.LOAD, ext) + + msg = f":x: Extension `{ext}` is already {verb}ed." + log.debug(msg[4:]) + except Exception as e: + if hasattr(e, "original"): + e = e.original + + log.exception(f"Extension '{ext}' failed to {verb}.") + + error_msg = f"{e.__class__.__name__}: {e}" + msg = f":x: Failed to {verb} extension `{ext}`:\n```\n{error_msg}\n```" + else: + msg = f":ok_hand: Extension successfully {verb}ed: `{ext}`." + log.debug(msg[10:]) + + return msg, error_msg + + # This cannot be static (must have a __func__ attribute). + def cog_check(self, ctx: Context) -> bool: + """Only allow moderators and core developers to invoke the commands in this cog.""" + return with_role_check(ctx, *MODERATION_ROLES, Roles.core_developers) + + # This cannot be static (must have a __func__ attribute). + async def cog_command_error(self, ctx: Context, error: Exception) -> None: + """Handle BadArgument errors locally to prevent the help command from showing.""" + if isinstance(error, commands.BadArgument): + await ctx.send(str(error)) + error.handled = True + + +def setup(bot: Bot) -> None: + """Load the Extensions cog.""" + bot.add_cog(Extensions(bot)) diff --git a/bot/exts/core/help.py b/bot/exts/core/help.py new file mode 100644 index 00000000..4b766b50 --- /dev/null +++ b/bot/exts/core/help.py @@ -0,0 +1,562 @@ +# 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 contextlib import suppress +from typing import NamedTuple, 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 rapidfuzz import process + +from bot import constants +from bot.bot import Bot +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", +} + + +class Cog(NamedTuple): + """Show information about a Cog's name, description and commands.""" + + name: str + description: str + commands: list[Command] + + +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.extract(query, choices, 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) + + # show signature if query is a command + if isinstance(self.query, commands.Command): + await self._add_command_signature(paginator) + + 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)): + await self._list_child_commands(paginator) + + self._pages = paginator.pages + + async def _add_command_signature(self, paginator: LinePaginator) -> None: + prefix = constants.Client.prefix + + signature = self._get_command_params(self.query) + parent = self.query.full_parent_name + " " if self.query.parent else "" + paginator.add_line(f"**```\n{prefix}{parent}{signature}\n```**") + aliases = [f"`{alias}`" if not parent else f"`{parent} {alias}`" for alias in self.query.aliases] + aliases += [f"`{alias}`" for alias in getattr(self.query, "root_aliases", ())] + aliases = ", ".join(sorted(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") + + async def _list_child_commands(self, paginator: LinePaginator) -> None: + # 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),) + + # 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: + await self._format_command_category(paginator, category, list(cmds)) + + async def _format_command_category(self, paginator: LinePaginator, category: str, cmds: list[Command]) -> None: + cmds = sorted(cmds, key=lambda c: c.name) + cat_cmds = [] + for command in cmds: + cat_cmds += await self._format_command(command) + + # 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) + + async def _format_command(self, command: Command) -> list[str]: + # skip if hidden and hide if session is set to + if command.hidden and not self._show_hidden: + return [] + + # 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 + + # see if the user can run the command + strikeout = "" + if not can_run: + # skip if we don't show commands they can't run + if self._only_can_run: + return [] + strikeout = "~~" + + if isinstance(self.query, commands.Command): + prefix = "" + else: + prefix = constants.Client.prefix + + signature = self._get_command_params(command) + info = f"{strikeout}**`{prefix}{signature}`**{strikeout}" + + # handle if the command has no docstring + short_doc = command.short_doc or "No details provided" + return [f"{info}\n*{short_doc}*"] + + 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: Bot) -> 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: Bot) -> 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: Bot) -> 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/core/internal_eval/__init__.py b/bot/exts/core/internal_eval/__init__.py new file mode 100644 index 00000000..695fa74d --- /dev/null +++ b/bot/exts/core/internal_eval/__init__.py @@ -0,0 +1,10 @@ +from bot.bot import Bot + + +def setup(bot: Bot) -> None: + """Set up the Internal Eval extension.""" + # Import the Cog at runtime to prevent side effects like defining + # RedisCache instances too early. + from ._internal_eval import InternalEval + + bot.add_cog(InternalEval(bot)) diff --git a/bot/exts/core/internal_eval/_helpers.py b/bot/exts/core/internal_eval/_helpers.py new file mode 100644 index 00000000..5b2f8f5d --- /dev/null +++ b/bot/exts/core/internal_eval/_helpers.py @@ -0,0 +1,248 @@ +import ast +import collections +import contextlib +import functools +import inspect +import io +import logging +import sys +import traceback +import types +from typing import Any, Optional, Union + +log = logging.getLogger(__name__) + +# A type alias to annotate the tuples returned from `sys.exc_info()` +ExcInfo = tuple[type[Exception], Exception, types.TracebackType] +Namespace = dict[str, Any] + +# This will be used as an coroutine function wrapper for the code +# to be evaluated. The wrapper contains one `pass` statement which +# will be replaced with `ast` with the code that we want to have +# evaluated. +# The function redirects output and captures exceptions that were +# raised in the code we evaluate. The latter is used to provide a +# meaningful traceback to the end user. +EVAL_WRAPPER = """ +async def _eval_wrapper_function(): + try: + with contextlib.redirect_stdout(_eval_context.stdout): + pass + if '_value_last_expression' in locals(): + if inspect.isawaitable(_value_last_expression): + _value_last_expression = await _value_last_expression + _eval_context._value_last_expression = _value_last_expression + else: + _eval_context._value_last_expression = None + except Exception: + _eval_context.exc_info = sys.exc_info() + finally: + _eval_context.locals = locals() +_eval_context.function = _eval_wrapper_function +""" +INTERNAL_EVAL_FRAMENAME = "" +EVAL_WRAPPER_FUNCTION_FRAMENAME = "_eval_wrapper_function" + + +def format_internal_eval_exception(exc_info: ExcInfo, code: str) -> str: + """Format an exception caught while evaluation code by inserting lines.""" + exc_type, exc_value, tb = exc_info + stack_summary = traceback.StackSummary.extract(traceback.walk_tb(tb)) + code = code.split("\n") + + output = ["Traceback (most recent call last):"] + for frame in stack_summary: + if frame.filename == INTERNAL_EVAL_FRAMENAME: + line = code[frame.lineno - 1].lstrip() + + if frame.name == EVAL_WRAPPER_FUNCTION_FRAMENAME: + name = INTERNAL_EVAL_FRAMENAME + else: + name = frame.name + else: + line = frame.line + name = frame.name + + output.append( + f' File "{frame.filename}", line {frame.lineno}, in {name}\n' + f" {line}" + ) + + output.extend(traceback.format_exception_only(exc_type, exc_value)) + return "\n".join(output) + + +class EvalContext: + """ + Represents the current `internal eval` context. + + The context remembers names set during earlier runs of `internal eval`. To + clear the context, use the `.internal clear` command. + """ + + def __init__(self, context_vars: Namespace, local_vars: Namespace): + self._locals = dict(local_vars) + self.context_vars = dict(context_vars) + + self.stdout = io.StringIO() + self._value_last_expression = None + self.exc_info = None + self.code = "" + self.function = None + self.eval_tree = None + + @property + def dependencies(self) -> dict[str, Any]: + """ + Return a mapping of the dependencies for the wrapper function. + + By using a property descriptor, the mapping can't be accidentally + mutated during evaluation. This ensures the dependencies are always + available. + """ + return { + "print": functools.partial(print, file=self.stdout), + "contextlib": contextlib, + "inspect": inspect, + "sys": sys, + "_eval_context": self, + "_": self._value_last_expression, + } + + @property + def locals(self) -> dict[str, Any]: + """Return a mapping of names->values needed for evaluation.""" + return {**collections.ChainMap(self.dependencies, self.context_vars, self._locals)} + + @locals.setter + def locals(self, locals_: dict[str, Any]) -> None: + """Update the contextual mapping of names to values.""" + log.trace(f"Updating {self._locals} with {locals_}") + self._locals.update(locals_) + + def prepare_eval(self, code: str) -> Optional[str]: + """Prepare an evaluation by processing the code and setting up the context.""" + self.code = code + + if not self.code: + log.debug("No code was attached to the evaluation command") + return "[No code detected]" + + try: + code_tree = ast.parse(code, filename=INTERNAL_EVAL_FRAMENAME) + except SyntaxError: + log.debug("Got a SyntaxError while parsing the eval code") + return "".join(traceback.format_exception(*sys.exc_info(), limit=0)) + + log.trace("Parsing the AST to see if there's a trailing expression we need to capture") + code_tree = CaptureLastExpression(code_tree).capture() + + log.trace("Wrapping the AST in the AST of the wrapper coroutine") + eval_tree = WrapEvalCodeTree(code_tree).wrap() + + self.eval_tree = eval_tree + return None + + async def run_eval(self) -> Namespace: + """Run the evaluation and return the updated locals.""" + log.trace("Compiling the AST to bytecode using `exec` mode") + compiled_code = compile(self.eval_tree, filename=INTERNAL_EVAL_FRAMENAME, mode="exec") + + log.trace("Executing the compiled code with the desired namespace environment") + exec(compiled_code, self.locals) # noqa: B102,S102 + + log.trace("Awaiting the created evaluation wrapper coroutine.") + await self.function() + + log.trace("Returning the updated captured locals.") + return self._locals + + def format_output(self) -> str: + """Format the output of the most recent evaluation.""" + output = [] + + log.trace(f"Getting output from stdout `{id(self.stdout)}`") + stdout_text = self.stdout.getvalue() + if stdout_text: + log.trace("Appending output captured from stdout/print") + output.append(stdout_text) + + if self._value_last_expression is not None: + log.trace("Appending the output of a captured trialing expression") + output.append(f"[Captured] {self._value_last_expression!r}") + + if self.exc_info: + log.trace("Appending exception information") + output.append(format_internal_eval_exception(self.exc_info, self.code)) + + log.trace(f"Generated output: {output!r}") + return "\n".join(output) or "[No output]" + + +class WrapEvalCodeTree(ast.NodeTransformer): + """Wraps the AST of eval code with the wrapper function.""" + + def __init__(self, eval_code_tree: ast.AST, *args, **kwargs): + super().__init__(*args, **kwargs) + self.eval_code_tree = eval_code_tree + + # To avoid mutable aliasing, parse the WRAPPER_FUNC for each wrapping + self.wrapper = ast.parse(EVAL_WRAPPER, filename=INTERNAL_EVAL_FRAMENAME) + + def wrap(self) -> ast.AST: + """Wrap the tree of the code by the tree of the wrapper function.""" + new_tree = self.visit(self.wrapper) + return ast.fix_missing_locations(new_tree) + + def visit_Pass(self, node: ast.Pass) -> list[ast.AST]: # noqa: N802 + """ + Replace the `_ast.Pass` node in the wrapper function by the eval AST. + + This method works on the assumption that there's a single `pass` + statement in the wrapper function. + """ + return list(ast.iter_child_nodes(self.eval_code_tree)) + + +class CaptureLastExpression(ast.NodeTransformer): + """Captures the return value from a loose expression.""" + + def __init__(self, tree: ast.AST, *args, **kwargs): + super().__init__(*args, **kwargs) + self.tree = tree + self.last_node = list(ast.iter_child_nodes(tree))[-1] + + def visit_Expr(self, node: ast.Expr) -> Union[ast.Expr, ast.Assign]: # noqa: N802 + """ + Replace the Expr node that is last child node of Module with an assignment. + + We use an assignment to capture the value of the last node, if it's a loose + Expr node. Normally, the value of an Expr node is lost, meaning we don't get + the output of such a last "loose" expression. By assigning it a name, we can + retrieve it for our output. + """ + if node is not self.last_node: + return node + + log.trace("Found a trailing last expression in the evaluation code") + + log.trace("Creating assignment statement with trailing expression as the right-hand side") + right_hand_side = list(ast.iter_child_nodes(node))[0] + + assignment = ast.Assign( + targets=[ast.Name(id='_value_last_expression', ctx=ast.Store())], + value=right_hand_side, + lineno=node.lineno, + col_offset=0, + ) + ast.fix_missing_locations(assignment) + return assignment + + def capture(self) -> ast.AST: + """Capture the value of the last expression with an assignment.""" + if not isinstance(self.last_node, ast.Expr): + # We only have to replace a node if the very last node is an Expr node + return self.tree + + new_tree = self.visit(self.tree) + return ast.fix_missing_locations(new_tree) diff --git a/bot/exts/core/internal_eval/_internal_eval.py b/bot/exts/core/internal_eval/_internal_eval.py new file mode 100644 index 00000000..4f6b4321 --- /dev/null +++ b/bot/exts/core/internal_eval/_internal_eval.py @@ -0,0 +1,179 @@ +import logging +import re +import textwrap +from typing import Optional + +import discord +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Client, Roles +from bot.utils.decorators import with_role +from bot.utils.extensions import invoke_help_command +from ._helpers import EvalContext + +__all__ = ["InternalEval"] + +log = logging.getLogger(__name__) + +FORMATTED_CODE_REGEX = re.compile( + r"(?P(?P```)|``?)" # code delimiter: 1-3 backticks; (?P=block) only matches if it's a block + r"(?(block)(?:(?P[a-z]+)\n)?)" # if we're in a block, match optional language (only letters plus newline) + r"(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code + r"(?P.*?)" # extract all code inside the markup + r"\s*" # any more whitespace before the end of the code markup + r"(?P=delim)", # match the exact same delimiter from the start again + re.DOTALL | re.IGNORECASE # "." also matches newlines, case insensitive +) + +RAW_CODE_REGEX = re.compile( + r"^(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code + r"(?P.*?)" # extract all the rest as code + r"\s*$", # any trailing whitespace until the end of the string + re.DOTALL # "." also matches newlines +) + + +class InternalEval(commands.Cog): + """Top secret code evaluation for admins and owners.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.locals = {} + + if Client.debug: + self.internal_group.add_check(commands.is_owner().predicate) + + @staticmethod + def shorten_output( + output: str, + max_length: int = 1900, + placeholder: str = "\n[output truncated]" + ) -> str: + """ + Shorten the `output` so it's shorter than `max_length`. + + There are three tactics for this, tried in the following order: + - Shorten the output on a line-by-line basis + - Shorten the output on any whitespace character + - Shorten the output solely on character count + """ + max_length = max_length - len(placeholder) + + shortened_output = [] + char_count = 0 + for line in output.split("\n"): + if char_count + len(line) > max_length: + break + shortened_output.append(line) + char_count += len(line) + 1 # account for (possible) line ending + + if shortened_output: + shortened_output.append(placeholder) + return "\n".join(shortened_output) + + shortened_output = textwrap.shorten(output, width=max_length, placeholder=placeholder) + + if shortened_output.strip() == placeholder.strip(): + # `textwrap` was unable to find whitespace to shorten on, so it has + # reduced the output to just the placeholder. Let's shorten based on + # characters instead. + shortened_output = output[:max_length] + placeholder + + return shortened_output + + async def _upload_output(self, output: str) -> Optional[str]: + """Upload `internal eval` output to our pastebin and return the url.""" + try: + async with self.bot.http_session.post( + "https://paste.pythondiscord.com/documents", data=output, raise_for_status=True + ) as resp: + data = await resp.json() + + if "key" in data: + return f"https://paste.pythondiscord.com/{data['key']}" + except Exception: + # 400 (Bad Request) means there are too many characters + log.exception("Failed to upload `internal eval` output to paste service!") + + async def _send_output(self, ctx: commands.Context, output: str) -> None: + """Send the `internal eval` output to the command invocation context.""" + upload_message = "" + if len(output) >= 1980: + # The output is too long, let's truncate it for in-channel output and + # upload the complete output to the paste service. + url = await self._upload_output(output) + + if url: + upload_message = f"\nFull output here: {url}" + else: + upload_message = "\n:warning: Failed to upload full output!" + + output = self.shorten_output(output) + + await ctx.send(f"```py\n{output}\n```{upload_message}") + + async def _eval(self, ctx: commands.Context, code: str) -> None: + """Evaluate the `code` in the current evaluation context.""" + context_vars = { + "message": ctx.message, + "author": ctx.author, + "channel": ctx.channel, + "guild": ctx.guild, + "ctx": ctx, + "self": self, + "bot": self.bot, + "discord": discord, + } + + eval_context = EvalContext(context_vars, self.locals) + + log.trace("Preparing the evaluation by parsing the AST of the code") + error = eval_context.prepare_eval(code) + + if error: + log.trace("The code can't be evaluated due to an error") + await ctx.send(f"```py\n{error}\n```") + return + + log.trace("Evaluate the AST we've generated for the evaluation") + new_locals = await eval_context.run_eval() + + log.trace("Updating locals with those set during evaluation") + self.locals.update(new_locals) + + log.trace("Sending the formatted output back to the context") + await self._send_output(ctx, eval_context.format_output()) + + @commands.group(name="internal", aliases=("int",)) + @with_role(Roles.admin) + async def internal_group(self, ctx: commands.Context) -> None: + """Internal commands. Top secret!""" + if not ctx.invoked_subcommand: + await invoke_help_command(ctx) + + @internal_group.command(name="eval", aliases=("e",)) + @with_role(Roles.admin) + async def eval(self, ctx: commands.Context, *, code: str) -> None: + """Run eval in a REPL-like format.""" + if match := list(FORMATTED_CODE_REGEX.finditer(code)): + blocks = [block for block in match if block.group("block")] + + if len(blocks) > 1: + code = "\n".join(block.group("code") for block in blocks) + else: + match = match[0] if len(blocks) == 0 else blocks[0] + code, block, lang, delim = match.group("code", "block", "lang", "delim") + + else: + code = RAW_CODE_REGEX.fullmatch(code).group("code") + + code = textwrap.dedent(code) + await self._eval(ctx, code) + + @internal_group.command(name="reset", aliases=("clear", "exit", "r", "c")) + @with_role(Roles.admin) + async def reset(self, ctx: commands.Context) -> None: + """Reset the context and locals of the eval session.""" + self.locals = {} + await ctx.send("The evaluation context was reset.") diff --git a/bot/exts/core/ping.py b/bot/exts/core/ping.py new file mode 100644 index 00000000..6be78117 --- /dev/null +++ b/bot/exts/core/ping.py @@ -0,0 +1,45 @@ +import arrow +from dateutil.relativedelta import relativedelta +from discord import Embed +from discord.ext import commands + +from bot import start_time +from bot.bot import Bot +from bot.constants import Colours + + +class Ping(commands.Cog): + """Get info about the bot's ping and uptime.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @commands.command(name="ping") + async def ping(self, ctx: commands.Context) -> None: + """Ping the bot to see its latency and state.""" + embed = Embed( + title=":ping_pong: Pong!", + colour=Colours.bright_green, + description=f"Gateway Latency: {round(self.bot.latency * 1000)}ms", + ) + + await ctx.send(embed=embed) + + # Originally made in 70d2170a0a6594561d59c7d080c4280f1ebcd70b by lemon & gdude2002 + @commands.command(name="uptime") + async def uptime(self, ctx: commands.Context) -> None: + """Get the current 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: Bot) -> None: + """Load the Ping cog.""" + bot.add_cog(Ping(bot)) diff --git a/bot/exts/core/source.py b/bot/exts/core/source.py new file mode 100644 index 00000000..7572ce51 --- /dev/null +++ b/bot/exts/core/source.py @@ -0,0 +1,85 @@ +import inspect +from pathlib import Path +from typing import Optional + +from discord import Embed +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Source +from bot.utils.converters import SourceConverter, SourceType + + +class BotSource(commands.Cog): + """Displays information about the bot's source code.""" + + @commands.command(name="source", aliases=("src",)) + async def source_command(self, ctx: commands.Context, *, source_item: SourceConverter = None) -> None: + """Display information and a GitHub link to the source code of a command, tag, or cog.""" + if not source_item: + embed = Embed(title="Sir Lancebot's GitHub Repository") + embed.add_field(name="Repository", value=f"[Go to GitHub]({Source.github})") + embed.set_thumbnail(url=Source.github_avatar_url) + await ctx.send(embed=embed) + return + + embed = await self.build_embed(source_item) + await ctx.send(embed=embed) + + def get_source_link(self, source_item: SourceType) -> tuple[str, str, Optional[int]]: + """ + Build GitHub link of source item, return this link, file location and first line number. + + Raise BadArgument if `source_item` is a dynamically-created object (e.g. via internal eval). + """ + if isinstance(source_item, commands.Command): + callback = inspect.unwrap(source_item.callback) + src = callback.__code__ + filename = src.co_filename + else: + src = type(source_item) + try: + filename = inspect.getsourcefile(src) + except TypeError: + raise commands.BadArgument("Cannot get source for a dynamically-created object.") + + if not isinstance(source_item, str): + try: + lines, first_line_no = inspect.getsourcelines(src) + except OSError: + raise commands.BadArgument("Cannot get source for a dynamically-created object.") + + lines_extension = f"#L{first_line_no}-L{first_line_no+len(lines)-1}" + else: + first_line_no = None + lines_extension = "" + + file_location = Path(filename).relative_to(Path.cwd()).as_posix() + + url = f"{Source.github}/blob/main/{file_location}{lines_extension}" + + return url, file_location, first_line_no or None + + async def build_embed(self, source_object: SourceType) -> Optional[Embed]: + """Build embed based on source object.""" + url, location, first_line = self.get_source_link(source_object) + + if isinstance(source_object, commands.Command): + description = source_object.short_doc + title = f"Command: {source_object.qualified_name}" + else: + title = f"Cog: {source_object.qualified_name}" + description = source_object.description.splitlines()[0] + + embed = Embed(title=title, description=description) + embed.set_thumbnail(url=Source.github_avatar_url) + embed.add_field(name="Source Code", value=f"[Go to GitHub]({url})") + line_text = f":{first_line}" if first_line else "" + embed.set_footer(text=f"{location}{line_text}") + + return embed + + +def setup(bot: Bot) -> None: + """Load the BotSource cog.""" + bot.add_cog(BotSource()) diff --git a/bot/exts/evergreen/error_handler.py b/bot/exts/evergreen/error_handler.py deleted file mode 100644 index fd2123e7..00000000 --- a/bot/exts/evergreen/error_handler.py +++ /dev/null @@ -1,182 +0,0 @@ -import difflib -import logging -import math -import random -from collections.abc import Iterable -from typing import Union - -from discord import Embed, Message -from discord.ext import commands -from sentry_sdk import push_scope - -from bot.bot import Bot -from bot.constants import Channels, Colours, ERROR_REPLIES, NEGATIVE_REPLIES, RedirectOutput -from bot.utils.decorators import InChannelCheckFailure, InMonthCheckFailure -from bot.utils.exceptions import APIError, UserNotPlayingError - -log = logging.getLogger(__name__) - - -QUESTION_MARK_ICON = "https://cdn.discordapp.com/emojis/512367613339369475.png" - - -class CommandErrorHandler(commands.Cog): - """A error handler for the PythonDiscord server.""" - - def __init__(self, bot: 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 raises an error.""" - if getattr(error, "handled", False): - logging.debug(f"Command {ctx.command} had its error already handled locally; ignoring.") - return - - parent_command = "" - if subctx := getattr(ctx, "subcontext", None): - parent_command = f"{ctx.command} " - ctx = subctx - - 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): - await self.send_command_suggestion(ctx, ctx.invoked_with) - 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) - usage = f"```\n{ctx.prefix}{parent_command}{ctx.command} {ctx.command.signature}\n```" - embed = self.error_embed( - f"Your input was invalid: {error}\n\nUsage:{usage}" - ) - 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( - f"This command can only be used in the server. Go to <#{Channels.community_bot_commands}> instead!", - 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```\n{ctx.prefix}{parent_command}{ctx.command} {ctx.command.signature}\n```" - ) - 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 - - if isinstance(error, UserNotPlayingError): - await ctx.send("Game not found.") - return - - if isinstance(error, APIError): - await ctx.send( - embed=self.error_embed( - f"There was an error when communicating with the {error.api}", - 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", ctx.message.jump_url) - - log.exception(f"Unhandled command error: {str(error)}", exc_info=error) - - async def send_command_suggestion(self, ctx: commands.Context, command_name: str) -> None: - """Sends user similar commands if any can be found.""" - raw_commands = [] - for cmd in self.bot.walk_commands(): - if not cmd.hidden: - raw_commands += (cmd.name, *cmd.aliases) - if similar_command_data := difflib.get_close_matches(command_name, raw_commands, 1): - similar_command_name = similar_command_data[0] - similar_command = self.bot.get_command(similar_command_name) - - if not similar_command: - return - - log_msg = "Cancelling attempt to suggest a command due to failed checks." - try: - if not await similar_command.can_run(ctx): - log.debug(log_msg) - return - except commands.errors.CommandError as cmd_error: - log.debug(log_msg) - await self.on_command_error(ctx, cmd_error) - return - - misspelled_content = ctx.message.content - e = Embed() - e.set_author(name="Did you mean:", icon_url=QUESTION_MARK_ICON) - e.description = misspelled_content.replace(command_name, similar_command_name, 1) - await ctx.send(embed=e, delete_after=RedirectOutput.delete_delay) - - -def setup(bot: Bot) -> None: - """Load the ErrorHandler cog.""" - bot.add_cog(CommandErrorHandler(bot)) diff --git a/bot/exts/evergreen/help.py b/bot/exts/evergreen/help.py deleted file mode 100644 index 4b766b50..00000000 --- a/bot/exts/evergreen/help.py +++ /dev/null @@ -1,562 +0,0 @@ -# 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 contextlib import suppress -from typing import NamedTuple, 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 rapidfuzz import process - -from bot import constants -from bot.bot import Bot -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", -} - - -class Cog(NamedTuple): - """Show information about a Cog's name, description and commands.""" - - name: str - description: str - commands: list[Command] - - -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.extract(query, choices, 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) - - # show signature if query is a command - if isinstance(self.query, commands.Command): - await self._add_command_signature(paginator) - - 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)): - await self._list_child_commands(paginator) - - self._pages = paginator.pages - - async def _add_command_signature(self, paginator: LinePaginator) -> None: - prefix = constants.Client.prefix - - signature = self._get_command_params(self.query) - parent = self.query.full_parent_name + " " if self.query.parent else "" - paginator.add_line(f"**```\n{prefix}{parent}{signature}\n```**") - aliases = [f"`{alias}`" if not parent else f"`{parent} {alias}`" for alias in self.query.aliases] - aliases += [f"`{alias}`" for alias in getattr(self.query, "root_aliases", ())] - aliases = ", ".join(sorted(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") - - async def _list_child_commands(self, paginator: LinePaginator) -> None: - # 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),) - - # 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: - await self._format_command_category(paginator, category, list(cmds)) - - async def _format_command_category(self, paginator: LinePaginator, category: str, cmds: list[Command]) -> None: - cmds = sorted(cmds, key=lambda c: c.name) - cat_cmds = [] - for command in cmds: - cat_cmds += await self._format_command(command) - - # 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) - - async def _format_command(self, command: Command) -> list[str]: - # skip if hidden and hide if session is set to - if command.hidden and not self._show_hidden: - return [] - - # 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 - - # see if the user can run the command - strikeout = "" - if not can_run: - # skip if we don't show commands they can't run - if self._only_can_run: - return [] - strikeout = "~~" - - if isinstance(self.query, commands.Command): - prefix = "" - else: - prefix = constants.Client.prefix - - signature = self._get_command_params(command) - info = f"{strikeout}**`{prefix}{signature}`**{strikeout}" - - # handle if the command has no docstring - short_doc = command.short_doc or "No details provided" - return [f"{info}\n*{short_doc}*"] - - 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: Bot) -> 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: Bot) -> 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: Bot) -> 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/ping.py b/bot/exts/evergreen/ping.py deleted file mode 100644 index 6be78117..00000000 --- a/bot/exts/evergreen/ping.py +++ /dev/null @@ -1,45 +0,0 @@ -import arrow -from dateutil.relativedelta import relativedelta -from discord import Embed -from discord.ext import commands - -from bot import start_time -from bot.bot import Bot -from bot.constants import Colours - - -class Ping(commands.Cog): - """Get info about the bot's ping and uptime.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @commands.command(name="ping") - async def ping(self, ctx: commands.Context) -> None: - """Ping the bot to see its latency and state.""" - embed = Embed( - title=":ping_pong: Pong!", - colour=Colours.bright_green, - description=f"Gateway Latency: {round(self.bot.latency * 1000)}ms", - ) - - await ctx.send(embed=embed) - - # Originally made in 70d2170a0a6594561d59c7d080c4280f1ebcd70b by lemon & gdude2002 - @commands.command(name="uptime") - async def uptime(self, ctx: commands.Context) -> None: - """Get the current 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: Bot) -> None: - """Load the Ping cog.""" - bot.add_cog(Ping(bot)) diff --git a/bot/exts/evergreen/source.py b/bot/exts/evergreen/source.py deleted file mode 100644 index 7572ce51..00000000 --- a/bot/exts/evergreen/source.py +++ /dev/null @@ -1,85 +0,0 @@ -import inspect -from pathlib import Path -from typing import Optional - -from discord import Embed -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Source -from bot.utils.converters import SourceConverter, SourceType - - -class BotSource(commands.Cog): - """Displays information about the bot's source code.""" - - @commands.command(name="source", aliases=("src",)) - async def source_command(self, ctx: commands.Context, *, source_item: SourceConverter = None) -> None: - """Display information and a GitHub link to the source code of a command, tag, or cog.""" - if not source_item: - embed = Embed(title="Sir Lancebot's GitHub Repository") - embed.add_field(name="Repository", value=f"[Go to GitHub]({Source.github})") - embed.set_thumbnail(url=Source.github_avatar_url) - await ctx.send(embed=embed) - return - - embed = await self.build_embed(source_item) - await ctx.send(embed=embed) - - def get_source_link(self, source_item: SourceType) -> tuple[str, str, Optional[int]]: - """ - Build GitHub link of source item, return this link, file location and first line number. - - Raise BadArgument if `source_item` is a dynamically-created object (e.g. via internal eval). - """ - if isinstance(source_item, commands.Command): - callback = inspect.unwrap(source_item.callback) - src = callback.__code__ - filename = src.co_filename - else: - src = type(source_item) - try: - filename = inspect.getsourcefile(src) - except TypeError: - raise commands.BadArgument("Cannot get source for a dynamically-created object.") - - if not isinstance(source_item, str): - try: - lines, first_line_no = inspect.getsourcelines(src) - except OSError: - raise commands.BadArgument("Cannot get source for a dynamically-created object.") - - lines_extension = f"#L{first_line_no}-L{first_line_no+len(lines)-1}" - else: - first_line_no = None - lines_extension = "" - - file_location = Path(filename).relative_to(Path.cwd()).as_posix() - - url = f"{Source.github}/blob/main/{file_location}{lines_extension}" - - return url, file_location, first_line_no or None - - async def build_embed(self, source_object: SourceType) -> Optional[Embed]: - """Build embed based on source object.""" - url, location, first_line = self.get_source_link(source_object) - - if isinstance(source_object, commands.Command): - description = source_object.short_doc - title = f"Command: {source_object.qualified_name}" - else: - title = f"Cog: {source_object.qualified_name}" - description = source_object.description.splitlines()[0] - - embed = Embed(title=title, description=description) - embed.set_thumbnail(url=Source.github_avatar_url) - embed.add_field(name="Source Code", value=f"[Go to GitHub]({url})") - line_text = f":{first_line}" if first_line else "" - embed.set_footer(text=f"{location}{line_text}") - - return embed - - -def setup(bot: Bot) -> None: - """Load the BotSource cog.""" - bot.add_cog(BotSource()) diff --git a/bot/exts/internal_eval/__init__.py b/bot/exts/internal_eval/__init__.py deleted file mode 100644 index 695fa74d..00000000 --- a/bot/exts/internal_eval/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -from bot.bot import Bot - - -def setup(bot: Bot) -> None: - """Set up the Internal Eval extension.""" - # Import the Cog at runtime to prevent side effects like defining - # RedisCache instances too early. - from ._internal_eval import InternalEval - - bot.add_cog(InternalEval(bot)) diff --git a/bot/exts/internal_eval/_helpers.py b/bot/exts/internal_eval/_helpers.py deleted file mode 100644 index 5b2f8f5d..00000000 --- a/bot/exts/internal_eval/_helpers.py +++ /dev/null @@ -1,248 +0,0 @@ -import ast -import collections -import contextlib -import functools -import inspect -import io -import logging -import sys -import traceback -import types -from typing import Any, Optional, Union - -log = logging.getLogger(__name__) - -# A type alias to annotate the tuples returned from `sys.exc_info()` -ExcInfo = tuple[type[Exception], Exception, types.TracebackType] -Namespace = dict[str, Any] - -# This will be used as an coroutine function wrapper for the code -# to be evaluated. The wrapper contains one `pass` statement which -# will be replaced with `ast` with the code that we want to have -# evaluated. -# The function redirects output and captures exceptions that were -# raised in the code we evaluate. The latter is used to provide a -# meaningful traceback to the end user. -EVAL_WRAPPER = """ -async def _eval_wrapper_function(): - try: - with contextlib.redirect_stdout(_eval_context.stdout): - pass - if '_value_last_expression' in locals(): - if inspect.isawaitable(_value_last_expression): - _value_last_expression = await _value_last_expression - _eval_context._value_last_expression = _value_last_expression - else: - _eval_context._value_last_expression = None - except Exception: - _eval_context.exc_info = sys.exc_info() - finally: - _eval_context.locals = locals() -_eval_context.function = _eval_wrapper_function -""" -INTERNAL_EVAL_FRAMENAME = "" -EVAL_WRAPPER_FUNCTION_FRAMENAME = "_eval_wrapper_function" - - -def format_internal_eval_exception(exc_info: ExcInfo, code: str) -> str: - """Format an exception caught while evaluation code by inserting lines.""" - exc_type, exc_value, tb = exc_info - stack_summary = traceback.StackSummary.extract(traceback.walk_tb(tb)) - code = code.split("\n") - - output = ["Traceback (most recent call last):"] - for frame in stack_summary: - if frame.filename == INTERNAL_EVAL_FRAMENAME: - line = code[frame.lineno - 1].lstrip() - - if frame.name == EVAL_WRAPPER_FUNCTION_FRAMENAME: - name = INTERNAL_EVAL_FRAMENAME - else: - name = frame.name - else: - line = frame.line - name = frame.name - - output.append( - f' File "{frame.filename}", line {frame.lineno}, in {name}\n' - f" {line}" - ) - - output.extend(traceback.format_exception_only(exc_type, exc_value)) - return "\n".join(output) - - -class EvalContext: - """ - Represents the current `internal eval` context. - - The context remembers names set during earlier runs of `internal eval`. To - clear the context, use the `.internal clear` command. - """ - - def __init__(self, context_vars: Namespace, local_vars: Namespace): - self._locals = dict(local_vars) - self.context_vars = dict(context_vars) - - self.stdout = io.StringIO() - self._value_last_expression = None - self.exc_info = None - self.code = "" - self.function = None - self.eval_tree = None - - @property - def dependencies(self) -> dict[str, Any]: - """ - Return a mapping of the dependencies for the wrapper function. - - By using a property descriptor, the mapping can't be accidentally - mutated during evaluation. This ensures the dependencies are always - available. - """ - return { - "print": functools.partial(print, file=self.stdout), - "contextlib": contextlib, - "inspect": inspect, - "sys": sys, - "_eval_context": self, - "_": self._value_last_expression, - } - - @property - def locals(self) -> dict[str, Any]: - """Return a mapping of names->values needed for evaluation.""" - return {**collections.ChainMap(self.dependencies, self.context_vars, self._locals)} - - @locals.setter - def locals(self, locals_: dict[str, Any]) -> None: - """Update the contextual mapping of names to values.""" - log.trace(f"Updating {self._locals} with {locals_}") - self._locals.update(locals_) - - def prepare_eval(self, code: str) -> Optional[str]: - """Prepare an evaluation by processing the code and setting up the context.""" - self.code = code - - if not self.code: - log.debug("No code was attached to the evaluation command") - return "[No code detected]" - - try: - code_tree = ast.parse(code, filename=INTERNAL_EVAL_FRAMENAME) - except SyntaxError: - log.debug("Got a SyntaxError while parsing the eval code") - return "".join(traceback.format_exception(*sys.exc_info(), limit=0)) - - log.trace("Parsing the AST to see if there's a trailing expression we need to capture") - code_tree = CaptureLastExpression(code_tree).capture() - - log.trace("Wrapping the AST in the AST of the wrapper coroutine") - eval_tree = WrapEvalCodeTree(code_tree).wrap() - - self.eval_tree = eval_tree - return None - - async def run_eval(self) -> Namespace: - """Run the evaluation and return the updated locals.""" - log.trace("Compiling the AST to bytecode using `exec` mode") - compiled_code = compile(self.eval_tree, filename=INTERNAL_EVAL_FRAMENAME, mode="exec") - - log.trace("Executing the compiled code with the desired namespace environment") - exec(compiled_code, self.locals) # noqa: B102,S102 - - log.trace("Awaiting the created evaluation wrapper coroutine.") - await self.function() - - log.trace("Returning the updated captured locals.") - return self._locals - - def format_output(self) -> str: - """Format the output of the most recent evaluation.""" - output = [] - - log.trace(f"Getting output from stdout `{id(self.stdout)}`") - stdout_text = self.stdout.getvalue() - if stdout_text: - log.trace("Appending output captured from stdout/print") - output.append(stdout_text) - - if self._value_last_expression is not None: - log.trace("Appending the output of a captured trialing expression") - output.append(f"[Captured] {self._value_last_expression!r}") - - if self.exc_info: - log.trace("Appending exception information") - output.append(format_internal_eval_exception(self.exc_info, self.code)) - - log.trace(f"Generated output: {output!r}") - return "\n".join(output) or "[No output]" - - -class WrapEvalCodeTree(ast.NodeTransformer): - """Wraps the AST of eval code with the wrapper function.""" - - def __init__(self, eval_code_tree: ast.AST, *args, **kwargs): - super().__init__(*args, **kwargs) - self.eval_code_tree = eval_code_tree - - # To avoid mutable aliasing, parse the WRAPPER_FUNC for each wrapping - self.wrapper = ast.parse(EVAL_WRAPPER, filename=INTERNAL_EVAL_FRAMENAME) - - def wrap(self) -> ast.AST: - """Wrap the tree of the code by the tree of the wrapper function.""" - new_tree = self.visit(self.wrapper) - return ast.fix_missing_locations(new_tree) - - def visit_Pass(self, node: ast.Pass) -> list[ast.AST]: # noqa: N802 - """ - Replace the `_ast.Pass` node in the wrapper function by the eval AST. - - This method works on the assumption that there's a single `pass` - statement in the wrapper function. - """ - return list(ast.iter_child_nodes(self.eval_code_tree)) - - -class CaptureLastExpression(ast.NodeTransformer): - """Captures the return value from a loose expression.""" - - def __init__(self, tree: ast.AST, *args, **kwargs): - super().__init__(*args, **kwargs) - self.tree = tree - self.last_node = list(ast.iter_child_nodes(tree))[-1] - - def visit_Expr(self, node: ast.Expr) -> Union[ast.Expr, ast.Assign]: # noqa: N802 - """ - Replace the Expr node that is last child node of Module with an assignment. - - We use an assignment to capture the value of the last node, if it's a loose - Expr node. Normally, the value of an Expr node is lost, meaning we don't get - the output of such a last "loose" expression. By assigning it a name, we can - retrieve it for our output. - """ - if node is not self.last_node: - return node - - log.trace("Found a trailing last expression in the evaluation code") - - log.trace("Creating assignment statement with trailing expression as the right-hand side") - right_hand_side = list(ast.iter_child_nodes(node))[0] - - assignment = ast.Assign( - targets=[ast.Name(id='_value_last_expression', ctx=ast.Store())], - value=right_hand_side, - lineno=node.lineno, - col_offset=0, - ) - ast.fix_missing_locations(assignment) - return assignment - - def capture(self) -> ast.AST: - """Capture the value of the last expression with an assignment.""" - if not isinstance(self.last_node, ast.Expr): - # We only have to replace a node if the very last node is an Expr node - return self.tree - - new_tree = self.visit(self.tree) - return ast.fix_missing_locations(new_tree) diff --git a/bot/exts/internal_eval/_internal_eval.py b/bot/exts/internal_eval/_internal_eval.py deleted file mode 100644 index 4f6b4321..00000000 --- a/bot/exts/internal_eval/_internal_eval.py +++ /dev/null @@ -1,179 +0,0 @@ -import logging -import re -import textwrap -from typing import Optional - -import discord -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Client, Roles -from bot.utils.decorators import with_role -from bot.utils.extensions import invoke_help_command -from ._helpers import EvalContext - -__all__ = ["InternalEval"] - -log = logging.getLogger(__name__) - -FORMATTED_CODE_REGEX = re.compile( - r"(?P(?P```)|``?)" # code delimiter: 1-3 backticks; (?P=block) only matches if it's a block - r"(?(block)(?:(?P[a-z]+)\n)?)" # if we're in a block, match optional language (only letters plus newline) - r"(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code - r"(?P.*?)" # extract all code inside the markup - r"\s*" # any more whitespace before the end of the code markup - r"(?P=delim)", # match the exact same delimiter from the start again - re.DOTALL | re.IGNORECASE # "." also matches newlines, case insensitive -) - -RAW_CODE_REGEX = re.compile( - r"^(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code - r"(?P.*?)" # extract all the rest as code - r"\s*$", # any trailing whitespace until the end of the string - re.DOTALL # "." also matches newlines -) - - -class InternalEval(commands.Cog): - """Top secret code evaluation for admins and owners.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.locals = {} - - if Client.debug: - self.internal_group.add_check(commands.is_owner().predicate) - - @staticmethod - def shorten_output( - output: str, - max_length: int = 1900, - placeholder: str = "\n[output truncated]" - ) -> str: - """ - Shorten the `output` so it's shorter than `max_length`. - - There are three tactics for this, tried in the following order: - - Shorten the output on a line-by-line basis - - Shorten the output on any whitespace character - - Shorten the output solely on character count - """ - max_length = max_length - len(placeholder) - - shortened_output = [] - char_count = 0 - for line in output.split("\n"): - if char_count + len(line) > max_length: - break - shortened_output.append(line) - char_count += len(line) + 1 # account for (possible) line ending - - if shortened_output: - shortened_output.append(placeholder) - return "\n".join(shortened_output) - - shortened_output = textwrap.shorten(output, width=max_length, placeholder=placeholder) - - if shortened_output.strip() == placeholder.strip(): - # `textwrap` was unable to find whitespace to shorten on, so it has - # reduced the output to just the placeholder. Let's shorten based on - # characters instead. - shortened_output = output[:max_length] + placeholder - - return shortened_output - - async def _upload_output(self, output: str) -> Optional[str]: - """Upload `internal eval` output to our pastebin and return the url.""" - try: - async with self.bot.http_session.post( - "https://paste.pythondiscord.com/documents", data=output, raise_for_status=True - ) as resp: - data = await resp.json() - - if "key" in data: - return f"https://paste.pythondiscord.com/{data['key']}" - except Exception: - # 400 (Bad Request) means there are too many characters - log.exception("Failed to upload `internal eval` output to paste service!") - - async def _send_output(self, ctx: commands.Context, output: str) -> None: - """Send the `internal eval` output to the command invocation context.""" - upload_message = "" - if len(output) >= 1980: - # The output is too long, let's truncate it for in-channel output and - # upload the complete output to the paste service. - url = await self._upload_output(output) - - if url: - upload_message = f"\nFull output here: {url}" - else: - upload_message = "\n:warning: Failed to upload full output!" - - output = self.shorten_output(output) - - await ctx.send(f"```py\n{output}\n```{upload_message}") - - async def _eval(self, ctx: commands.Context, code: str) -> None: - """Evaluate the `code` in the current evaluation context.""" - context_vars = { - "message": ctx.message, - "author": ctx.author, - "channel": ctx.channel, - "guild": ctx.guild, - "ctx": ctx, - "self": self, - "bot": self.bot, - "discord": discord, - } - - eval_context = EvalContext(context_vars, self.locals) - - log.trace("Preparing the evaluation by parsing the AST of the code") - error = eval_context.prepare_eval(code) - - if error: - log.trace("The code can't be evaluated due to an error") - await ctx.send(f"```py\n{error}\n```") - return - - log.trace("Evaluate the AST we've generated for the evaluation") - new_locals = await eval_context.run_eval() - - log.trace("Updating locals with those set during evaluation") - self.locals.update(new_locals) - - log.trace("Sending the formatted output back to the context") - await self._send_output(ctx, eval_context.format_output()) - - @commands.group(name="internal", aliases=("int",)) - @with_role(Roles.admin) - async def internal_group(self, ctx: commands.Context) -> None: - """Internal commands. Top secret!""" - if not ctx.invoked_subcommand: - await invoke_help_command(ctx) - - @internal_group.command(name="eval", aliases=("e",)) - @with_role(Roles.admin) - async def eval(self, ctx: commands.Context, *, code: str) -> None: - """Run eval in a REPL-like format.""" - if match := list(FORMATTED_CODE_REGEX.finditer(code)): - blocks = [block for block in match if block.group("block")] - - if len(blocks) > 1: - code = "\n".join(block.group("code") for block in blocks) - else: - match = match[0] if len(blocks) == 0 else blocks[0] - code, block, lang, delim = match.group("code", "block", "lang", "delim") - - else: - code = RAW_CODE_REGEX.fullmatch(code).group("code") - - code = textwrap.dedent(code) - await self._eval(ctx, code) - - @internal_group.command(name="reset", aliases=("clear", "exit", "r", "c")) - @with_role(Roles.admin) - async def reset(self, ctx: commands.Context) -> None: - """Reset the context and locals of the eval session.""" - self.locals = {} - await ctx.send("The evaluation context was reset.") diff --git a/bot/exts/utils/__init__.py b/bot/exts/utils/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/bot/exts/utils/extensions.py b/bot/exts/utils/extensions.py deleted file mode 100644 index 424bacac..00000000 --- a/bot/exts/utils/extensions.py +++ /dev/null @@ -1,266 +0,0 @@ -import functools -import logging -from collections.abc import Mapping -from enum import Enum -from typing import Optional - -from discord import Colour, Embed -from discord.ext import commands -from discord.ext.commands import Context, group - -from bot import exts -from bot.bot import Bot -from bot.constants import Client, Emojis, MODERATION_ROLES, Roles -from bot.utils.checks import with_role_check -from bot.utils.extensions import EXTENSIONS, invoke_help_command, unqualify -from bot.utils.pagination import LinePaginator - -log = logging.getLogger(__name__) - - -UNLOAD_BLACKLIST = {f"{exts.__name__}.utils.extensions"} -BASE_PATH_LEN = len(exts.__name__.split(".")) - - -class Action(Enum): - """Represents an action to perform on an extension.""" - - # Need to be partial otherwise they are considered to be function definitions. - LOAD = functools.partial(Bot.load_extension) - UNLOAD = functools.partial(Bot.unload_extension) - RELOAD = functools.partial(Bot.reload_extension) - - -class Extension(commands.Converter): - """ - Fully qualify the name of an extension and ensure it exists. - - The * and ** values bypass this when used with the reload command. - """ - - async def convert(self, ctx: Context, argument: str) -> str: - """Fully qualify the name of an extension and ensure it exists.""" - # Special values to reload all extensions - if argument == "*" or argument == "**": - return argument - - argument = argument.lower() - - if argument in EXTENSIONS: - return argument - elif (qualified_arg := f"{exts.__name__}.{argument}") in EXTENSIONS: - return qualified_arg - - matches = [] - for ext in EXTENSIONS: - if argument == unqualify(ext): - matches.append(ext) - - if len(matches) > 1: - matches.sort() - names = "\n".join(matches) - raise commands.BadArgument( - f":x: `{argument}` is an ambiguous extension name. " - f"Please use one of the following fully-qualified names.```\n{names}\n```" - ) - elif matches: - return matches[0] - else: - raise commands.BadArgument(f":x: Could not find the extension `{argument}`.") - - -class Extensions(commands.Cog): - """Extension management commands.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @group(name="extensions", aliases=("ext", "exts", "c", "cogs"), invoke_without_command=True) - async def extensions_group(self, ctx: Context) -> None: - """Load, unload, reload, and list loaded extensions.""" - await invoke_help_command(ctx) - - @extensions_group.command(name="load", aliases=("l",)) - async def load_command(self, ctx: Context, *extensions: Extension) -> None: - r""" - Load extensions given their fully qualified or unqualified names. - - If '\*' or '\*\*' is given as the name, all unloaded extensions will be loaded. - """ # noqa: W605 - if not extensions: - await invoke_help_command(ctx) - return - - if "*" in extensions or "**" in extensions: - extensions = set(EXTENSIONS) - set(self.bot.extensions.keys()) - - msg = self.batch_manage(Action.LOAD, *extensions) - await ctx.send(msg) - - @extensions_group.command(name="unload", aliases=("ul",)) - async def unload_command(self, ctx: Context, *extensions: Extension) -> None: - r""" - Unload currently loaded extensions given their fully qualified or unqualified names. - - If '\*' or '\*\*' is given as the name, all loaded extensions will be unloaded. - """ # noqa: W605 - if not extensions: - await invoke_help_command(ctx) - return - - blacklisted = "\n".join(UNLOAD_BLACKLIST & set(extensions)) - - if blacklisted: - msg = f":x: The following extension(s) may not be unloaded:```\n{blacklisted}\n```" - else: - if "*" in extensions or "**" in extensions: - extensions = set(self.bot.extensions.keys()) - UNLOAD_BLACKLIST - - msg = self.batch_manage(Action.UNLOAD, *extensions) - - await ctx.send(msg) - - @extensions_group.command(name="reload", aliases=("r",), root_aliases=("reload",)) - async def reload_command(self, ctx: Context, *extensions: Extension) -> None: - r""" - Reload extensions given their fully qualified or unqualified names. - - If an extension fails to be reloaded, it will be rolled-back to the prior working state. - - If '\*' is given as the name, all currently loaded extensions will be reloaded. - If '\*\*' is given as the name, all extensions, including unloaded ones, will be reloaded. - """ # noqa: W605 - if not extensions: - await invoke_help_command(ctx) - return - - if "**" in extensions: - extensions = EXTENSIONS - elif "*" in extensions: - extensions = set(self.bot.extensions.keys()) | set(extensions) - extensions.remove("*") - - msg = self.batch_manage(Action.RELOAD, *extensions) - - await ctx.send(msg) - - @extensions_group.command(name="list", aliases=("all",)) - async def list_command(self, ctx: Context) -> None: - """ - Get a list of all extensions, including their loaded status. - - Grey indicates that the extension is unloaded. - Green indicates that the extension is currently loaded. - """ - embed = Embed(colour=Colour.blurple()) - embed.set_author( - name="Extensions List", - url=Client.github_bot_repo, - icon_url=str(self.bot.user.display_avatar.url) - ) - - lines = [] - categories = self.group_extension_statuses() - for category, extensions in sorted(categories.items()): - # Treat each category as a single line by concatenating everything. - # This ensures the paginator will not cut off a page in the middle of a category. - category = category.replace("_", " ").title() - extensions = "\n".join(sorted(extensions)) - lines.append(f"**{category}**\n{extensions}\n") - - log.debug(f"{ctx.author} requested a list of all cogs. Returning a paginated list.") - await LinePaginator.paginate(lines, ctx, embed, max_size=1200, empty=False) - - def group_extension_statuses(self) -> Mapping[str, str]: - """Return a mapping of extension names and statuses to their categories.""" - categories = {} - - for ext in EXTENSIONS: - if ext in self.bot.extensions: - status = Emojis.status_online - else: - status = Emojis.status_offline - - path = ext.split(".") - if len(path) > BASE_PATH_LEN + 1: - category = " - ".join(path[BASE_PATH_LEN:-1]) - else: - category = "uncategorised" - - categories.setdefault(category, []).append(f"{status} {path[-1]}") - - return categories - - def batch_manage(self, action: Action, *extensions: str) -> str: - """ - Apply an action to multiple extensions and return a message with the results. - - If only one extension is given, it is deferred to `manage()`. - """ - if len(extensions) == 1: - msg, _ = self.manage(action, extensions[0]) - return msg - - verb = action.name.lower() - failures = {} - - for extension in extensions: - _, error = self.manage(action, extension) - if error: - failures[extension] = error - - emoji = ":x:" if failures else ":ok_hand:" - msg = f"{emoji} {len(extensions) - len(failures)} / {len(extensions)} extensions {verb}ed." - - if failures: - failures = "\n".join(f"{ext}\n {err}" for ext, err in failures.items()) - msg += f"\nFailures:```\n{failures}\n```" - - log.debug(f"Batch {verb}ed extensions.") - - return msg - - def manage(self, action: Action, ext: str) -> tuple[str, Optional[str]]: - """Apply an action to an extension and return the status message and any error message.""" - verb = action.name.lower() - error_msg = None - - try: - action.value(self.bot, ext) - except (commands.ExtensionAlreadyLoaded, commands.ExtensionNotLoaded): - if action is Action.RELOAD: - # When reloading, just load the extension if it was not loaded. - return self.manage(Action.LOAD, ext) - - msg = f":x: Extension `{ext}` is already {verb}ed." - log.debug(msg[4:]) - except Exception as e: - if hasattr(e, "original"): - e = e.original - - log.exception(f"Extension '{ext}' failed to {verb}.") - - error_msg = f"{e.__class__.__name__}: {e}" - msg = f":x: Failed to {verb} extension `{ext}`:\n```\n{error_msg}\n```" - else: - msg = f":ok_hand: Extension successfully {verb}ed: `{ext}`." - log.debug(msg[10:]) - - return msg, error_msg - - # This cannot be static (must have a __func__ attribute). - def cog_check(self, ctx: Context) -> bool: - """Only allow moderators and core developers to invoke the commands in this cog.""" - return with_role_check(ctx, *MODERATION_ROLES, Roles.core_developers) - - # This cannot be static (must have a __func__ attribute). - async def cog_command_error(self, ctx: Context, error: Exception) -> None: - """Handle BadArgument errors locally to prevent the help command from showing.""" - if isinstance(error, commands.BadArgument): - await ctx.send(str(error)) - error.handled = True - - -def setup(bot: Bot) -> None: - """Load the Extensions cog.""" - bot.add_cog(Extensions(bot)) -- cgit v1.2.3 From 66c888ad68ad88ba1d39c2ac4824469560b8c29a Mon Sep 17 00:00:00 2001 From: Janine vN Date: Sun, 5 Sep 2021 00:12:47 -0400 Subject: Move practical functions into utilities folder Separates out the useful/practical seasonal bot features from the evergreen folder into a "utilities" folder. Adjusts the paths to resources to reflect the folder move. --- bot/exts/evergreen/bookmark.py | 153 ------------ bot/exts/evergreen/cheatsheet.py | 112 --------- bot/exts/evergreen/conversationstarters.py | 69 ------ bot/exts/evergreen/emoji.py | 123 ---------- bot/exts/evergreen/githubinfo.py | 178 -------------- bot/exts/evergreen/issues.py | 275 --------------------- bot/exts/evergreen/latex.py | 101 -------- bot/exts/evergreen/pythonfacts.py | 36 --- bot/exts/evergreen/realpython.py | 81 ------- bot/exts/evergreen/reddit.py | 368 ----------------------------- bot/exts/evergreen/stackoverflow.py | 88 ------- bot/exts/evergreen/timed.py | 48 ---- bot/exts/evergreen/wikipedia.py | 100 -------- bot/exts/evergreen/wolfram.py | 293 ----------------------- bot/exts/utilities/__init__.py | 0 bot/exts/utilities/bookmark.py | 153 ++++++++++++ bot/exts/utilities/cheatsheet.py | 112 +++++++++ bot/exts/utilities/conversationstarters.py | 69 ++++++ bot/exts/utilities/emoji.py | 123 ++++++++++ bot/exts/utilities/githubinfo.py | 178 ++++++++++++++ bot/exts/utilities/issues.py | 275 +++++++++++++++++++++ bot/exts/utilities/latex.py | 101 ++++++++ bot/exts/utilities/pythonfacts.py | 36 +++ bot/exts/utilities/realpython.py | 81 +++++++ bot/exts/utilities/reddit.py | 368 +++++++++++++++++++++++++++++ bot/exts/utilities/stackoverflow.py | 88 +++++++ bot/exts/utilities/timed.py | 48 ++++ bot/exts/utilities/wikipedia.py | 100 ++++++++ bot/exts/utilities/wolfram.py | 293 +++++++++++++++++++++++ bot/resources/evergreen/py_topics.yaml | 139 ----------- bot/resources/evergreen/python_facts.txt | 3 - bot/resources/evergreen/starter.yaml | 51 ---- bot/resources/utilities/py_topics.yaml | 139 +++++++++++ bot/resources/utilities/python_facts.txt | 3 + bot/resources/utilities/starter.yaml | 51 ++++ 35 files changed, 2218 insertions(+), 2218 deletions(-) delete mode 100644 bot/exts/evergreen/bookmark.py delete mode 100644 bot/exts/evergreen/cheatsheet.py delete mode 100644 bot/exts/evergreen/conversationstarters.py delete mode 100644 bot/exts/evergreen/emoji.py delete mode 100644 bot/exts/evergreen/githubinfo.py delete mode 100644 bot/exts/evergreen/issues.py delete mode 100644 bot/exts/evergreen/latex.py delete mode 100644 bot/exts/evergreen/pythonfacts.py delete mode 100644 bot/exts/evergreen/realpython.py delete mode 100644 bot/exts/evergreen/reddit.py delete mode 100644 bot/exts/evergreen/stackoverflow.py delete mode 100644 bot/exts/evergreen/timed.py delete mode 100644 bot/exts/evergreen/wikipedia.py delete mode 100644 bot/exts/evergreen/wolfram.py create mode 100644 bot/exts/utilities/__init__.py create mode 100644 bot/exts/utilities/bookmark.py create mode 100644 bot/exts/utilities/cheatsheet.py create mode 100644 bot/exts/utilities/conversationstarters.py create mode 100644 bot/exts/utilities/emoji.py create mode 100644 bot/exts/utilities/githubinfo.py create mode 100644 bot/exts/utilities/issues.py create mode 100644 bot/exts/utilities/latex.py create mode 100644 bot/exts/utilities/pythonfacts.py create mode 100644 bot/exts/utilities/realpython.py create mode 100644 bot/exts/utilities/reddit.py create mode 100644 bot/exts/utilities/stackoverflow.py create mode 100644 bot/exts/utilities/timed.py create mode 100644 bot/exts/utilities/wikipedia.py create mode 100644 bot/exts/utilities/wolfram.py delete mode 100644 bot/resources/evergreen/py_topics.yaml delete mode 100644 bot/resources/evergreen/python_facts.txt delete mode 100644 bot/resources/evergreen/starter.yaml create mode 100644 bot/resources/utilities/py_topics.yaml create mode 100644 bot/resources/utilities/python_facts.txt create mode 100644 bot/resources/utilities/starter.yaml (limited to 'bot') diff --git a/bot/exts/evergreen/bookmark.py b/bot/exts/evergreen/bookmark.py deleted file mode 100644 index a91ef1c0..00000000 --- a/bot/exts/evergreen/bookmark.py +++ /dev/null @@ -1,153 +0,0 @@ -import asyncio -import logging -import random -from typing import Optional - -import discord -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Categories, Colours, ERROR_REPLIES, Icons, WHITELISTED_CHANNELS -from bot.utils.converters import WrappedMessageConverter -from bot.utils.decorators import whitelist_override - -log = logging.getLogger(__name__) - -# Number of seconds to wait for other users to bookmark the same message -TIMEOUT = 120 -BOOKMARK_EMOJI = "📌" -WHITELISTED_CATEGORIES = (Categories.help_in_use,) - - -class Bookmark(commands.Cog): - """Creates personal bookmarks by relaying a message link to the user's DMs.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @staticmethod - def build_bookmark_dm(target_message: discord.Message, title: str) -> discord.Embed: - """Build the embed to DM the bookmark requester.""" - embed = discord.Embed( - title=title, - description=target_message.content, - colour=Colours.soft_green - ) - 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.display_avatar.url) - embed.set_thumbnail(url=Icons.bookmark) - - return embed - - @staticmethod - def build_error_embed(user: discord.Member) -> discord.Embed: - """Builds an error embed for when a bookmark requester has DMs disabled.""" - return discord.Embed( - title=random.choice(ERROR_REPLIES), - description=f"{user.mention}, please enable your DMs to receive the bookmark.", - colour=Colours.soft_red - ) - - async def action_bookmark( - self, - channel: discord.TextChannel, - user: discord.Member, - target_message: discord.Message, - title: str - ) -> None: - """Sends the bookmark DM, or sends an error embed when a user bookmarks a message.""" - try: - embed = self.build_bookmark_dm(target_message, title) - await user.send(embed=embed) - except discord.Forbidden: - error_embed = self.build_error_embed(user) - await channel.send(embed=error_embed) - else: - log.info(f"{user} bookmarked {target_message.jump_url} with title '{title}'") - - @staticmethod - async def send_reaction_embed( - channel: discord.TextChannel, - target_message: discord.Message - ) -> discord.Message: - """Sends an embed, with a reaction, so users can react to bookmark the message too.""" - message = await channel.send( - embed=discord.Embed( - description=( - f"React with {BOOKMARK_EMOJI} to be sent your very own bookmark to " - f"[this message]({target_message.jump_url})." - ), - colour=Colours.soft_green - ) - ) - - await message.add_reaction(BOOKMARK_EMOJI) - return message - - @whitelist_override(channels=WHITELISTED_CHANNELS, categories=WHITELISTED_CATEGORIES) - @commands.command(name="bookmark", aliases=("bm", "pin")) - async def bookmark( - self, - ctx: commands.Context, - target_message: Optional[WrappedMessageConverter], - *, - title: str = "Bookmark" - ) -> None: - """Send the author a link to `target_message` via DMs.""" - if not target_message: - if not ctx.message.reference: - raise commands.UserInputError("You must either provide a valid message to bookmark, or reply to one.") - target_message = ctx.message.reference.resolved - - # Prevent users from bookmarking a message in a channel they don't have access to - permissions = target_message.channel.permissions_for(ctx.author) - 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 - - def event_check(reaction: discord.Reaction, user: discord.Member) -> bool: - """Make sure that this reaction is what we want to operate on.""" - return ( - # Conditions for a successful pagination: - all(( - # Reaction is on this message - reaction.message.id == reaction_message.id, - # User has not already bookmarked this message - user.id not in bookmarked_users, - # Reaction is the `BOOKMARK_EMOJI` emoji - str(reaction.emoji) == BOOKMARK_EMOJI, - # Reaction was not made by the Bot - user.id != self.bot.user.id - )) - ) - await self.action_bookmark(ctx.channel, ctx.author, target_message, title) - - # Keep track of who has already bookmarked, so users can't spam reactions and cause loads of DMs - bookmarked_users = [ctx.author.id] - reaction_message = await self.send_reaction_embed(ctx.channel, target_message) - - while True: - try: - _, user = await self.bot.wait_for("reaction_add", timeout=TIMEOUT, check=event_check) - except asyncio.TimeoutError: - log.debug("Timed out waiting for a reaction") - break - log.trace(f"{user} has successfully bookmarked from a reaction, attempting to DM them.") - await self.action_bookmark(ctx.channel, user, target_message, title) - bookmarked_users.append(user.id) - - await reaction_message.delete() - - -def setup(bot: Bot) -> None: - """Load the Bookmark cog.""" - bot.add_cog(Bookmark(bot)) diff --git a/bot/exts/evergreen/cheatsheet.py b/bot/exts/evergreen/cheatsheet.py deleted file mode 100644 index 33d29f67..00000000 --- a/bot/exts/evergreen/cheatsheet.py +++ /dev/null @@ -1,112 +0,0 @@ -import random -import re -from typing import Union -from urllib.parse import quote_plus - -from discord import Embed -from discord.ext import commands -from discord.ext.commands import BucketType, Context - -from bot import constants -from bot.bot import Bot -from bot.constants import Categories, Channels, Colours, ERROR_REPLIES -from bot.utils.decorators import whitelist_override - -ERROR_MESSAGE = f""" -Unknown cheat sheet. Please try to reformulate your query. - -**Examples**: -```md -{constants.Client.prefix}cht read json -{constants.Client.prefix}cht hello world -{constants.Client.prefix}cht lambda -``` -If the problem persists send a message in <#{Channels.dev_contrib}> -""" - -URL = "https://cheat.sh/python/{search}" -ESCAPE_TT = str.maketrans({"`": "\\`"}) -ANSI_RE = re.compile(r"\x1b\[.*?m") -# We need to pass headers as curl otherwise it would default to aiohttp which would return raw html. -HEADERS = {"User-Agent": "curl/7.68.0"} - - -class CheatSheet(commands.Cog): - """Commands that sends a result of a cht.sh search in code blocks.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @staticmethod - def fmt_error_embed() -> Embed: - """ - Format the Error Embed. - - If the cht.sh search returned 404, overwrite it to send a custom error embed. - link -> https://github.com/chubin/cheat.sh/issues/198 - """ - embed = Embed( - title=random.choice(ERROR_REPLIES), - description=ERROR_MESSAGE, - colour=Colours.soft_red - ) - return embed - - def result_fmt(self, url: str, body_text: str) -> tuple[bool, Union[str, Embed]]: - """Format Result.""" - if body_text.startswith("# 404 NOT FOUND"): - embed = self.fmt_error_embed() - return True, embed - - body_space = min(1986 - len(url), 1000) - - if len(body_text) > body_space: - description = ( - f"**Result Of cht.sh**\n" - f"```python\n{body_text[:body_space]}\n" - f"... (truncated - too many lines)\n```\n" - f"Full results: {url} " - ) - else: - description = ( - f"**Result Of cht.sh**\n" - f"```python\n{body_text}\n```\n" - f"{url}" - ) - return False, description - - @commands.command( - name="cheat", - aliases=("cht.sh", "cheatsheet", "cheat-sheet", "cht"), - ) - @commands.cooldown(1, 10, BucketType.user) - @whitelist_override(categories=[Categories.help_in_use]) - async def cheat_sheet(self, ctx: Context, *search_terms: str) -> None: - """ - Search cheat.sh. - - Gets a post from https://cheat.sh/python/ by default. - Usage: - --> .cht read json - """ - async with ctx.typing(): - search_string = quote_plus(" ".join(search_terms)) - - async with self.bot.http_session.get( - URL.format(search=search_string), headers=HEADERS - ) as response: - result = ANSI_RE.sub("", await response.text()).translate(ESCAPE_TT) - - is_embed, description = self.result_fmt( - URL.format(search=search_string), - result - ) - if is_embed: - await ctx.send(embed=description) - else: - await ctx.send(content=description) - - -def setup(bot: Bot) -> None: - """Load the CheatSheet cog.""" - bot.add_cog(CheatSheet(bot)) diff --git a/bot/exts/evergreen/conversationstarters.py b/bot/exts/evergreen/conversationstarters.py deleted file mode 100644 index fdc4467a..00000000 --- a/bot/exts/evergreen/conversationstarters.py +++ /dev/null @@ -1,69 +0,0 @@ -from pathlib import Path - -import yaml -from discord import Color, Embed -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import WHITELISTED_CHANNELS -from bot.utils.decorators import whitelist_override -from bot.utils.randomization import RandomCycle - -SUGGESTION_FORM = "https://forms.gle/zw6kkJqv8U43Nfjg9" - -with Path("bot/resources/evergreen/starter.yaml").open("r", encoding="utf8") as f: - STARTERS = yaml.load(f, Loader=yaml.FullLoader) - -with Path("bot/resources/evergreen/py_topics.yaml").open("r", encoding="utf8") as f: - # First ID is #python-general and the rest are top to bottom categories of Topical Chat/Help. - PY_TOPICS = yaml.load(f, Loader=yaml.FullLoader) - - # Removing `None` from lists of topics, if not a list, it is changed to an empty one. - PY_TOPICS = {k: [i for i in v if i] if isinstance(v, list) else [] for k, v in PY_TOPICS.items()} - - # All the allowed channels that the ".topic" command is allowed to be executed in. - ALL_ALLOWED_CHANNELS = list(PY_TOPICS.keys()) + list(WHITELISTED_CHANNELS) - -# Putting all topics into one dictionary and shuffling lists to reduce same-topic repetitions. -ALL_TOPICS = {"default": STARTERS, **PY_TOPICS} -TOPICS = { - channel: RandomCycle(topics or ["No topics found for this channel."]) - for channel, topics in ALL_TOPICS.items() -} - - -class ConvoStarters(commands.Cog): - """Evergreen conversation topics.""" - - @commands.command() - @whitelist_override(channels=ALL_ALLOWED_CHANNELS) - async def topic(self, ctx: commands.Context) -> None: - """ - Responds with a random topic to start a conversation. - - If in a Python channel, a python-related topic will be given. - - Otherwise, a random conversation topic will be received by the user. - """ - # No matter what, the form will be shown. - embed = Embed(description=f"Suggest more topics [here]({SUGGESTION_FORM})!", color=Color.blurple()) - - try: - # Fetching topics. - channel_topics = TOPICS[ctx.channel.id] - - # If the channel isn't Python-related. - except KeyError: - embed.title = f"**{next(TOPICS['default'])}**" - - # If the channel ID doesn't have any topics. - else: - embed.title = f"**{next(channel_topics)}**" - - finally: - await ctx.send(embed=embed) - - -def setup(bot: Bot) -> None: - """Load the ConvoStarters cog.""" - bot.add_cog(ConvoStarters()) diff --git a/bot/exts/evergreen/emoji.py b/bot/exts/evergreen/emoji.py deleted file mode 100644 index 55d6b8e9..00000000 --- a/bot/exts/evergreen/emoji.py +++ /dev/null @@ -1,123 +0,0 @@ -import logging -import random -import textwrap -from collections import defaultdict -from datetime import datetime -from typing import Optional - -from discord import Color, Embed, Emoji -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Colours, ERROR_REPLIES -from bot.utils.extensions import invoke_help_command -from bot.utils.pagination import LinePaginator -from bot.utils.time import time_since - -log = logging.getLogger(__name__) - - -class Emojis(commands.Cog): - """A collection of commands related to emojis in the server.""" - - @staticmethod - def embed_builder(emoji: dict) -> tuple[Embed, list[str]]: - """Generates an embed with the emoji names and count.""" - embed = Embed( - color=Colours.orange, - title="Emoji Count", - timestamp=datetime.utcnow() - ) - msg = [] - - if len(emoji) == 1: - for category_name, category_emojis in emoji.items(): - if len(category_emojis) == 1: - msg.append(f"There is **{len(category_emojis)}** emoji in the **{category_name}** category.") - else: - msg.append(f"There are **{len(category_emojis)}** emojis in the **{category_name}** category.") - embed.set_thumbnail(url=random.choice(category_emojis).url) - - else: - for category_name, category_emojis in emoji.items(): - emoji_choice = random.choice(category_emojis) - if len(category_emojis) > 1: - emoji_info = f"There are **{len(category_emojis)}** emojis in the **{category_name}** category." - else: - emoji_info = f"There is **{len(category_emojis)}** emoji in the **{category_name}** category." - if emoji_choice.animated: - msg.append(f" {emoji_info}") - else: - msg.append(f"<:{emoji_choice.name}:{emoji_choice.id}> {emoji_info}") - return embed, msg - - @staticmethod - def generate_invalid_embed(emojis: list[Emoji]) -> tuple[Embed, list[str]]: - """Generates error embed for invalid emoji categories.""" - embed = Embed( - color=Colours.soft_red, - title=random.choice(ERROR_REPLIES) - ) - msg = [] - - emoji_dict = defaultdict(list) - for emoji in emojis: - emoji_dict[emoji.name.split("_")[0]].append(emoji) - - error_comp = ", ".join(emoji_dict) - msg.append(f"These are the valid emoji categories:\n```\n{error_comp}\n```") - return embed, msg - - @commands.group(name="emoji", invoke_without_command=True) - async def emoji_group(self, ctx: commands.Context, emoji: Optional[Emoji]) -> None: - """A group of commands related to emojis.""" - if emoji is not None: - await ctx.invoke(self.info_command, emoji) - else: - await invoke_help_command(ctx) - - @emoji_group.command(name="count", aliases=("c",)) - async def count_command(self, ctx: commands.Context, *, category_query: str = None) -> None: - """Returns embed with emoji category and info given by the user.""" - emoji_dict = defaultdict(list) - - if not ctx.guild.emojis: - await ctx.send("No emojis found.") - return - log.trace(f"Emoji Category {'' if category_query else 'not '}provided by the user.") - for emoji in ctx.guild.emojis: - emoji_category = emoji.name.split("_")[0] - - if category_query is not None and emoji_category not in category_query: - continue - - emoji_dict[emoji_category].append(emoji) - - if not emoji_dict: - log.trace("Invalid name provided by the user") - embed, msg = self.generate_invalid_embed(ctx.guild.emojis) - else: - embed, msg = self.embed_builder(emoji_dict) - await LinePaginator.paginate(lines=msg, ctx=ctx, embed=embed) - - @emoji_group.command(name="info", aliases=("i",)) - async def info_command(self, ctx: commands.Context, emoji: Emoji) -> None: - """Returns relevant information about a Discord Emoji.""" - emoji_information = Embed( - title=f"Emoji Information: {emoji.name}", - description=textwrap.dedent(f""" - **Name:** {emoji.name} - **Created:** {time_since(emoji.created_at, precision="hours")} - **Date:** {datetime.strftime(emoji.created_at, "%d/%m/%Y")} - **ID:** {emoji.id} - """), - color=Color.blurple(), - url=str(emoji.url), - ).set_thumbnail(url=emoji.url) - - await ctx.send(embed=emoji_information) - - -def setup(bot: Bot) -> None: - """Load the Emojis cog.""" - bot.add_cog(Emojis()) diff --git a/bot/exts/evergreen/githubinfo.py b/bot/exts/evergreen/githubinfo.py deleted file mode 100644 index bbc9061a..00000000 --- a/bot/exts/evergreen/githubinfo.py +++ /dev/null @@ -1,178 +0,0 @@ -import logging -import random -from datetime import datetime -from urllib.parse import quote, quote_plus - -import discord -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Colours, NEGATIVE_REPLIES -from bot.exts.utils.extensions import invoke_help_command - -log = logging.getLogger(__name__) - -GITHUB_API_URL = "https://api.github.com" - - -class GithubInfo(commands.Cog): - """Fetches info from GitHub.""" - - def __init__(self, bot: Bot): - self.bot = bot - - async def fetch_data(self, url: str) -> dict: - """Retrieve data as a dictionary.""" - async with self.bot.http_session.get(url) as r: - return await r.json() - - @commands.group(name="github", aliases=("gh", "git")) - @commands.cooldown(1, 10, commands.BucketType.user) - async def github_group(self, ctx: commands.Context) -> None: - """Commands for finding information related to GitHub.""" - if ctx.invoked_subcommand is None: - await invoke_help_command(ctx) - - @github_group.command(name="user", aliases=("userinfo",)) - async def github_user_info(self, ctx: commands.Context, username: str) -> None: - """Fetches a user's GitHub information.""" - async with ctx.typing(): - user_data = await self.fetch_data(f"{GITHUB_API_URL}/users/{quote_plus(username)}") - - # User_data will not have a message key if the user exists - if "message" in user_data: - embed = discord.Embed( - title=random.choice(NEGATIVE_REPLIES), - description=f"The profile for `{username}` was not found.", - colour=Colours.soft_red - ) - - await ctx.send(embed=embed) - return - - org_data = await self.fetch_data(user_data["organizations_url"]) - orgs = [f"[{org['login']}](https://github.com/{org['login']})" for org in org_data] - orgs_to_add = " | ".join(orgs) - - gists = user_data["public_gists"] - - # Forming blog link - if user_data["blog"].startswith("http"): # Blog link is complete - blog = user_data["blog"] - elif user_data["blog"]: # Blog exists but the link is not complete - blog = f"https://{user_data['blog']}" - else: - blog = "No website link available" - - embed = discord.Embed( - title=f"`{user_data['login']}`'s GitHub profile info", - description=f"```\n{user_data['bio']}\n```\n" if user_data["bio"] else "", - colour=discord.Colour.blurple(), - url=user_data["html_url"], - timestamp=datetime.strptime(user_data["created_at"], "%Y-%m-%dT%H:%M:%SZ") - ) - embed.set_thumbnail(url=user_data["avatar_url"]) - embed.set_footer(text="Account created at") - - if user_data["type"] == "User": - - embed.add_field( - name="Followers", - value=f"[{user_data['followers']}]({user_data['html_url']}?tab=followers)" - ) - embed.add_field( - name="Following", - value=f"[{user_data['following']}]({user_data['html_url']}?tab=following)" - ) - - embed.add_field( - name="Public repos", - value=f"[{user_data['public_repos']}]({user_data['html_url']}?tab=repositories)" - ) - - if user_data["type"] == "User": - embed.add_field( - name="Gists", - value=f"[{gists}](https://gist.github.com/{quote_plus(username, safe='')})" - ) - - embed.add_field( - name=f"Organization{'s' if len(orgs)!=1 else ''}", - value=orgs_to_add if orgs else "No organizations." - ) - embed.add_field(name="Website", value=blog) - - await ctx.send(embed=embed) - - @github_group.command(name='repository', aliases=('repo',)) - async def github_repo_info(self, ctx: commands.Context, *repo: str) -> None: - """ - Fetches a repositories' GitHub information. - - The repository should look like `user/reponame` or `user reponame`. - """ - repo = "/".join(repo) - if repo.count("/") != 1: - embed = discord.Embed( - title=random.choice(NEGATIVE_REPLIES), - description="The repository should look like `user/reponame` or `user reponame`.", - colour=Colours.soft_red - ) - - await ctx.send(embed=embed) - return - - async with ctx.typing(): - repo_data = await self.fetch_data(f"{GITHUB_API_URL}/repos/{quote(repo)}") - - # There won't be a message key if this repo exists - if "message" in repo_data: - embed = discord.Embed( - title=random.choice(NEGATIVE_REPLIES), - description="The requested repository was not found.", - colour=Colours.soft_red - ) - - await ctx.send(embed=embed) - return - - embed = discord.Embed( - title=repo_data["name"], - description=repo_data["description"], - colour=discord.Colour.blurple(), - url=repo_data["html_url"] - ) - - # If it's a fork, then it will have a parent key - try: - parent = repo_data["parent"] - embed.description += f"\n\nForked from [{parent['full_name']}]({parent['html_url']})" - except KeyError: - log.debug("Repository is not a fork.") - - repo_owner = repo_data["owner"] - - embed.set_author( - name=repo_owner["login"], - url=repo_owner["html_url"], - icon_url=repo_owner["avatar_url"] - ) - - repo_created_at = datetime.strptime(repo_data["created_at"], "%Y-%m-%dT%H:%M:%SZ").strftime("%d/%m/%Y") - last_pushed = datetime.strptime(repo_data["pushed_at"], "%Y-%m-%dT%H:%M:%SZ").strftime("%d/%m/%Y at %H:%M") - - embed.set_footer( - text=( - f"{repo_data['forks_count']} ⑂ " - f"• {repo_data['stargazers_count']} ⭐ " - f"• Created At {repo_created_at} " - f"• Last Commit {last_pushed}" - ) - ) - - await ctx.send(embed=embed) - - -def setup(bot: Bot) -> None: - """Load the GithubInfo cog.""" - bot.add_cog(GithubInfo(bot)) diff --git a/bot/exts/evergreen/issues.py b/bot/exts/evergreen/issues.py deleted file mode 100644 index 8a7ebed0..00000000 --- a/bot/exts/evergreen/issues.py +++ /dev/null @@ -1,275 +0,0 @@ -import logging -import random -import re -from dataclasses import dataclass -from typing import Optional, Union - -import discord -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import ( - Categories, - Channels, - Colours, - ERROR_REPLIES, - Emojis, - NEGATIVE_REPLIES, - Tokens, - WHITELISTED_CHANNELS -) -from bot.utils.decorators import whitelist_override -from bot.utils.extensions import invoke_help_command - -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!" -} -REQUEST_HEADERS = { - "Accept": "application/vnd.github.v3+json" -} - -REPOSITORY_ENDPOINT = "https://api.github.com/orgs/{org}/repos?per_page=100&type=public" -ISSUE_ENDPOINT = "https://api.github.com/repos/{user}/{repository}/issues/{number}" -PR_ENDPOINT = "https://api.github.com/repos/{user}/{repository}/pulls/{number}" - -if GITHUB_TOKEN := Tokens.github: - REQUEST_HEADERS["Authorization"] = f"token {GITHUB_TOKEN}" - -WHITELISTED_CATEGORIES = ( - Categories.development, Categories.devprojects, Categories.media, Categories.staff -) - -CODE_BLOCK_RE = re.compile( - r"^`([^`\n]+)`" # Inline codeblock - r"|```(.+?)```", # Multiline codeblock - re.DOTALL | re.MULTILINE -) - -# Maximum number of issues in one message -MAXIMUM_ISSUES = 5 - -# Regex used when looking for automatic linking in messages -# regex101 of current regex https://regex101.com/r/V2ji8M/6 -AUTOMATIC_REGEX = re.compile( - r"((?P[a-zA-Z0-9][a-zA-Z0-9\-]{1,39})\/)?(?P[\w\-\.]{1,100})#(?P[0-9]+)" -) - - -@dataclass -class FoundIssue: - """Dataclass representing an issue found by the regex.""" - - organisation: Optional[str] - repository: str - number: str - - def __hash__(self) -> int: - return hash((self.organisation, self.repository, self.number)) - - -@dataclass -class FetchError: - """Dataclass representing an error while fetching an issue.""" - - return_code: int - message: str - - -@dataclass -class IssueState: - """Dataclass representing the state of an issue.""" - - repository: str - number: int - url: str - title: str - emoji: str - - -class Issues(commands.Cog): - """Cog that allows users to retrieve issues from GitHub.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.repos = [] - - @staticmethod - def remove_codeblocks(message: str) -> str: - """Remove any codeblock in a message.""" - return re.sub(CODE_BLOCK_RE, "", message) - - async def fetch_issues( - self, - number: int, - repository: str, - user: str - ) -> Union[IssueState, FetchError]: - """ - Retrieve an issue from a GitHub repository. - - Returns IssueState on success, FetchError on failure. - """ - url = ISSUE_ENDPOINT.format(user=user, repository=repository, number=number) - pulls_url = PR_ENDPOINT.format(user=user, repository=repository, number=number) - log.trace(f"Querying GH issues API: {url}") - - async with self.bot.http_session.get(url, headers=REQUEST_HEADERS) as r: - json_data = await r.json() - - if r.status == 403: - if r.headers.get("X-RateLimit-Remaining") == "0": - log.info(f"Ratelimit reached while fetching {url}") - return FetchError(403, "Ratelimit reached, please retry in a few minutes.") - return FetchError(403, "Cannot access issue.") - elif r.status in (404, 410): - return FetchError(r.status, "Issue not found.") - elif r.status != 200: - return FetchError(r.status, "Error while fetching issue.") - - # 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["html_url"]: - if json_data.get("state") == "open": - emoji = Emojis.issue_open - else: - emoji = 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: {pulls_url}") - async with self.bot.http_session.get(pulls_url) as p: - pull_data = await p.json() - if pull_data["draft"]: - emoji = Emojis.pull_request_draft - elif pull_data["state"] == "open": - emoji = Emojis.pull_request_open - # When 'merged_at' is not None, this means that the state of the PR is merged - elif pull_data["merged_at"] is not None: - emoji = Emojis.pull_request_merged - else: - emoji = Emojis.pull_request_closed - - issue_url = json_data.get("html_url") - - return IssueState(repository, number, issue_url, json_data.get("title", ""), emoji) - - @staticmethod - def format_embed( - results: list[Union[IssueState, FetchError]], - user: str, - repository: Optional[str] = None - ) -> discord.Embed: - """Take a list of IssueState or FetchError and format a Discord embed for them.""" - description_list = [] - - for result in results: - if isinstance(result, IssueState): - description_list.append(f"{result.emoji} [{result.title}]({result.url})") - elif isinstance(result, FetchError): - description_list.append(f":x: [{result.return_code}] {result.message}") - - resp = discord.Embed( - colour=Colours.bright_green, - description="\n".join(description_list) - ) - - embed_url = f"https://github.com/{user}/{repository}" if repository else f"https://github.com/{user}" - resp.set_author(name="GitHub", url=embed_url) - return resp - - @whitelist_override(channels=WHITELISTED_CHANNELS, categories=WHITELISTED_CATEGORIES) - @commands.command(aliases=("pr",)) - async def issue( - self, - ctx: commands.Context, - numbers: commands.Greedy[int], - repository: str = "sir-lancebot", - user: str = "python-discord" - ) -> None: - """Command to retrieve issue(s) from a GitHub repository.""" - # Remove duplicates - numbers = set(numbers) - - if len(numbers) > MAXIMUM_ISSUES: - embed = discord.Embed( - title=random.choice(ERROR_REPLIES), - color=Colours.soft_red, - description=f"Too many issues/PRs! (maximum of {MAXIMUM_ISSUES})" - ) - await ctx.send(embed=embed) - await invoke_help_command(ctx) - - results = [await self.fetch_issues(number, repository, user) for number in numbers] - await ctx.send(embed=self.format_embed(results, user, repository)) - - @commands.Cog.listener() - async def on_message(self, message: discord.Message) -> None: - """ - Automatic issue linking. - - Listener to retrieve issue(s) from a GitHub repository using automatic linking if matching /#. - """ - # Ignore bots - if message.author.bot: - return - - issues = [ - FoundIssue(*match.group("org", "repo", "number")) - for match in AUTOMATIC_REGEX.finditer(self.remove_codeblocks(message.content)) - ] - links = [] - - if issues: - # Block this from working in DMs - if not message.guild: - await message.channel.send( - embed=discord.Embed( - title=random.choice(NEGATIVE_REPLIES), - description=( - "You can't retrieve issues from DMs. " - f"Try again in <#{Channels.community_bot_commands}>" - ), - colour=Colours.soft_red - ) - ) - return - - log.trace(f"Found {issues = }") - # Remove duplicates - issues = set(issues) - - if len(issues) > MAXIMUM_ISSUES: - embed = discord.Embed( - title=random.choice(ERROR_REPLIES), - color=Colours.soft_red, - description=f"Too many issues/PRs! (maximum of {MAXIMUM_ISSUES})" - ) - await message.channel.send(embed=embed, delete_after=5) - return - - for repo_issue in issues: - result = await self.fetch_issues( - int(repo_issue.number), - repo_issue.repository, - repo_issue.organisation or "python-discord" - ) - if isinstance(result, IssueState): - links.append(result) - - if not links: - return - - resp = self.format_embed(links, "python-discord") - await message.channel.send(embed=resp) - - -def setup(bot: Bot) -> None: - """Load the Issues cog.""" - bot.add_cog(Issues(bot)) diff --git a/bot/exts/evergreen/latex.py b/bot/exts/evergreen/latex.py deleted file mode 100644 index 36c7e0ab..00000000 --- a/bot/exts/evergreen/latex.py +++ /dev/null @@ -1,101 +0,0 @@ -import asyncio -import hashlib -import pathlib -import re -from concurrent.futures import ThreadPoolExecutor -from io import BytesIO - -import discord -import matplotlib.pyplot as plt -from discord.ext import commands - -from bot.bot import Bot - -# configure fonts and colors for matplotlib -plt.rcParams.update( - { - "font.size": 16, - "mathtext.fontset": "cm", # Computer Modern font set - "mathtext.rm": "serif", - "figure.facecolor": "36393F", # matches Discord's dark mode background color - "text.color": "white", - } -) - -FORMATTED_CODE_REGEX = re.compile( - r"(?P(?P```)|``?)" # code delimiter: 1-3 backticks; (?P=block) only matches if it's a block - r"(?(block)(?:(?P[a-z]+)\n)?)" # if we're in a block, match optional language (only letters plus newline) - r"(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code - r"(?P.*?)" # extract all code inside the markup - r"\s*" # any more whitespace before the end of the code markup - r"(?P=delim)", # match the exact same delimiter from the start again - re.DOTALL | re.IGNORECASE, # "." also matches newlines, case insensitive -) - -CACHE_DIRECTORY = pathlib.Path("_latex_cache") -CACHE_DIRECTORY.mkdir(exist_ok=True) - - -class Latex(commands.Cog): - """Renders latex.""" - - @staticmethod - def _render(text: str, filepath: pathlib.Path) -> BytesIO: - """ - Return the rendered image if latex compiles without errors, otherwise raise a BadArgument Exception. - - Saves rendered image to cache. - """ - fig = plt.figure() - rendered_image = BytesIO() - fig.text(0, 1, text, horizontalalignment="left", verticalalignment="top") - - try: - plt.savefig(rendered_image, bbox_inches="tight", dpi=600) - except ValueError as e: - raise commands.BadArgument(str(e)) - - rendered_image.seek(0) - - with open(filepath, "wb") as f: - f.write(rendered_image.getbuffer()) - - return rendered_image - - @staticmethod - def _prepare_input(text: str) -> str: - text = text.replace(r"\\", "$\n$") # matplotlib uses \n for newlines, not \\ - - if match := FORMATTED_CODE_REGEX.match(text): - return match.group("code") - else: - return text - - @commands.command() - @commands.max_concurrency(1, commands.BucketType.guild, wait=True) - async def latex(self, ctx: commands.Context, *, text: str) -> None: - """Renders the text in latex and sends the image.""" - text = self._prepare_input(text) - query_hash = hashlib.md5(text.encode()).hexdigest() - image_path = CACHE_DIRECTORY.joinpath(f"{query_hash}.png") - async with ctx.typing(): - if image_path.exists(): - await ctx.send(file=discord.File(image_path)) - return - - with ThreadPoolExecutor() as pool: - image = await asyncio.get_running_loop().run_in_executor( - pool, self._render, text, image_path - ) - - await ctx.send(file=discord.File(image, "latex.png")) - - -def setup(bot: Bot) -> None: - """Load the Latex Cog.""" - # As we have resource issues on this cog, - # we have it currently disabled while we fix it. - import logging - logging.info("Latex cog is currently disabled. It won't be loaded.") - return - bot.add_cog(Latex()) diff --git a/bot/exts/evergreen/pythonfacts.py b/bot/exts/evergreen/pythonfacts.py deleted file mode 100644 index 80a8da5d..00000000 --- a/bot/exts/evergreen/pythonfacts.py +++ /dev/null @@ -1,36 +0,0 @@ -import itertools - -import discord -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Colours - -with open("bot/resources/evergreen/python_facts.txt") as file: - FACTS = itertools.cycle(list(file)) - -COLORS = itertools.cycle([Colours.python_blue, Colours.python_yellow]) -PYFACTS_DISCUSSION = "https://github.com/python-discord/meta/discussions/93" - - -class PythonFacts(commands.Cog): - """Sends a random fun fact about Python.""" - - @commands.command(name="pythonfact", aliases=("pyfact",)) - async def get_python_fact(self, ctx: commands.Context) -> None: - """Sends a Random fun fact about Python.""" - embed = discord.Embed( - title="Python Facts", - description=next(FACTS), - colour=next(COLORS) - ) - embed.add_field( - name="Suggestions", - value=f"Suggest more facts [here!]({PYFACTS_DISCUSSION})" - ) - await ctx.send(embed=embed) - - -def setup(bot: Bot) -> None: - """Load the PythonFacts Cog.""" - bot.add_cog(PythonFacts()) diff --git a/bot/exts/evergreen/realpython.py b/bot/exts/evergreen/realpython.py deleted file mode 100644 index ef8b2638..00000000 --- a/bot/exts/evergreen/realpython.py +++ /dev/null @@ -1,81 +0,0 @@ -import logging -from html import unescape -from urllib.parse import quote_plus - -from discord import Embed -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Colours - -logger = logging.getLogger(__name__) - - -API_ROOT = "https://realpython.com/search/api/v1/" -ARTICLE_URL = "https://realpython.com{article_url}" -SEARCH_URL = "https://realpython.com/search?q={user_search}" - - -ERROR_EMBED = Embed( - title="Error while searching Real Python", - description="There was an error while trying to reach Real Python. Please try again shortly.", - color=Colours.soft_red, -) - - -class RealPython(commands.Cog): - """User initiated command to search for a Real Python article.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @commands.command(aliases=["rp"]) - @commands.cooldown(1, 10, commands.cooldowns.BucketType.user) - async def realpython(self, ctx: commands.Context, *, user_search: str) -> None: - """Send 5 articles that match the user's search terms.""" - params = {"q": user_search, "limit": 5, "kind": "article"} - async with self.bot.http_session.get(url=API_ROOT, params=params) as response: - if response.status != 200: - logger.error( - f"Unexpected status code {response.status} from Real Python" - ) - await ctx.send(embed=ERROR_EMBED) - return - - data = await response.json() - - articles = data["results"] - - if len(articles) == 0: - no_articles = Embed( - title=f"No articles found for '{user_search}'", color=Colours.soft_red - ) - await ctx.send(embed=no_articles) - return - - if len(articles) == 1: - article_description = "Here is the result:" - else: - article_description = f"Here are the top {len(articles)} results:" - - article_embed = Embed( - title="Search results - Real Python", - url=SEARCH_URL.format(user_search=quote_plus(user_search)), - description=article_description, - color=Colours.orange, - ) - - for article in articles: - article_embed.add_field( - name=unescape(article["title"]), - value=ARTICLE_URL.format(article_url=article["url"]), - inline=False, - ) - article_embed.set_footer(text="Click the links to go to the articles.") - - await ctx.send(embed=article_embed) - - -def setup(bot: Bot) -> None: - """Load the Real Python Cog.""" - bot.add_cog(RealPython(bot)) diff --git a/bot/exts/evergreen/reddit.py b/bot/exts/evergreen/reddit.py deleted file mode 100644 index e6cb5337..00000000 --- a/bot/exts/evergreen/reddit.py +++ /dev/null @@ -1,368 +0,0 @@ -import asyncio -import logging -import random -import textwrap -from collections import namedtuple -from datetime import datetime, timedelta -from typing import Union - -from aiohttp import BasicAuth, ClientError -from discord import Colour, Embed, TextChannel -from discord.ext.commands import Cog, Context, group, has_any_role -from discord.ext.tasks import loop -from discord.utils import escape_markdown, sleep_until - -from bot.bot import Bot -from bot.constants import Channels, ERROR_REPLIES, Emojis, Reddit as RedditConfig, STAFF_ROLES -from bot.utils.converters import Subreddit -from bot.utils.extensions import invoke_help_command -from bot.utils.messages import sub_clyde -from bot.utils.pagination import ImagePaginator, LinePaginator - -log = logging.getLogger(__name__) - -AccessToken = namedtuple("AccessToken", ["token", "expires_at"]) - - -class Reddit(Cog): - """Track subreddit posts and show detailed statistics about them.""" - - HEADERS = {"User-Agent": "python3:python-discord/bot:1.0.0 (by /u/PythonDiscord)"} - URL = "https://www.reddit.com" - OAUTH_URL = "https://oauth.reddit.com" - MAX_RETRIES = 3 - - def __init__(self, bot: Bot): - self.bot = bot - - self.webhook = None - self.access_token = None - self.client_auth = BasicAuth(RedditConfig.client_id, RedditConfig.secret) - - bot.loop.create_task(self.init_reddit_ready()) - self.auto_poster_loop.start() - - def cog_unload(self) -> None: - """Stop the loop task and revoke the access token when the cog is unloaded.""" - self.auto_poster_loop.cancel() - if self.access_token and self.access_token.expires_at > datetime.utcnow(): - asyncio.create_task(self.revoke_access_token()) - - async def init_reddit_ready(self) -> None: - """Sets the reddit webhook when the cog is loaded.""" - await self.bot.wait_until_guild_available() - if not self.webhook: - self.webhook = await self.bot.fetch_webhook(RedditConfig.webhook) - - @property - def channel(self) -> TextChannel: - """Get the #reddit channel object from the bot's cache.""" - return self.bot.get_channel(Channels.reddit) - - def build_pagination_pages(self, posts: list[dict], paginate: bool) -> Union[list[tuple], str]: - """Build embed pages required for Paginator.""" - pages = [] - first_page = "" - for post in posts: - post_page = "" - image_url = "" - - data = post["data"] - - title = textwrap.shorten(data["title"], width=50, placeholder="...") - - # Normal brackets interfere with Markdown. - title = escape_markdown(title).replace("[", "⦋").replace("]", "⦌") - link = self.URL + data["permalink"] - - first_page += f"**[{title.replace('*', '')}]({link})**\n" - - text = data["selftext"] - if text: - text = escape_markdown(text).replace("[", "⦋").replace("]", "⦌") - first_page += textwrap.shorten(text, width=100, placeholder="...") + "\n" - - ups = data["ups"] - comments = data["num_comments"] - author = data["author"] - - content_type = Emojis.reddit_post_text - if data["is_video"] or {"youtube", "youtu.be"}.issubset(set(data["url"].split("."))): - # This means the content type in the post is a video. - content_type = f"{Emojis.reddit_post_video}" - - elif data["url"].endswith(("jpg", "png", "gif")): - # This means the content type in the post is an image. - content_type = f"{Emojis.reddit_post_photo}" - image_url = data["url"] - - first_page += ( - f"{content_type}\u2003{Emojis.reddit_upvote}{ups}\u2003{Emojis.reddit_comments}" - f"\u2002{comments}\u2003{Emojis.reddit_users}{author}\n\n" - ) - - if paginate: - post_page += f"**[{title}]({link})**\n\n" - if text: - post_page += textwrap.shorten(text, width=252, placeholder="...") + "\n\n" - post_page += ( - f"{content_type}\u2003{Emojis.reddit_upvote}{ups}\u2003{Emojis.reddit_comments}\u2002" - f"{comments}\u2003{Emojis.reddit_users}{author}" - ) - - pages.append((post_page, image_url)) - - if not paginate: - # Return the first summery page if pagination is not required - return first_page - - pages.insert(0, (first_page, "")) # Using image paginator, hence settings image url to empty string - return pages - - async def get_access_token(self) -> None: - """ - Get a Reddit API OAuth2 access token and assign it to self.access_token. - - A token is valid for 1 hour. There will be MAX_RETRIES to get a token, after which the cog - will be unloaded and a ClientError raised if retrieval was still unsuccessful. - """ - for i in range(1, self.MAX_RETRIES + 1): - response = await self.bot.http_session.post( - url=f"{self.URL}/api/v1/access_token", - headers=self.HEADERS, - auth=self.client_auth, - data={ - "grant_type": "client_credentials", - "duration": "temporary" - } - ) - - if response.status == 200 and response.content_type == "application/json": - content = await response.json() - expiration = int(content["expires_in"]) - 60 # Subtract 1 minute for leeway. - self.access_token = AccessToken( - token=content["access_token"], - expires_at=datetime.utcnow() + timedelta(seconds=expiration) - ) - - log.debug(f"New token acquired; expires on UTC {self.access_token.expires_at}") - return - else: - log.debug( - f"Failed to get an access token: " - f"status {response.status} & content type {response.content_type}; " - f"retrying ({i}/{self.MAX_RETRIES})" - ) - - await asyncio.sleep(3) - - self.bot.remove_cog(self.qualified_name) - raise ClientError("Authentication with the Reddit API failed. Unloading the cog.") - - async def revoke_access_token(self) -> None: - """ - Revoke the OAuth2 access token for the Reddit API. - - For security reasons, it's good practice to revoke the token when it's no longer being used. - """ - response = await self.bot.http_session.post( - url=f"{self.URL}/api/v1/revoke_token", - headers=self.HEADERS, - auth=self.client_auth, - data={ - "token": self.access_token.token, - "token_type_hint": "access_token" - } - ) - - if response.status in [200, 204] and response.content_type == "application/json": - self.access_token = None - else: - log.warning(f"Unable to revoke access token: status {response.status}.") - - async def fetch_posts(self, route: str, *, amount: int = 25, params: dict = None) -> list[dict]: - """A helper method to fetch a certain amount of Reddit posts at a given route.""" - # Reddit's JSON responses only provide 25 posts at most. - if not 25 >= amount > 0: - raise ValueError("Invalid amount of subreddit posts requested.") - - # Renew the token if necessary. - if not self.access_token or self.access_token.expires_at < datetime.utcnow(): - await self.get_access_token() - - url = f"{self.OAUTH_URL}/{route}" - for _ in range(self.MAX_RETRIES): - response = await self.bot.http_session.get( - url=url, - headers={**self.HEADERS, "Authorization": f"bearer {self.access_token.token}"}, - params=params - ) - if response.status == 200 and response.content_type == 'application/json': - # Got appropriate response - process and return. - content = await response.json() - posts = content["data"]["children"] - - filtered_posts = [post for post in posts if not post["data"]["over_18"]] - - return filtered_posts[:amount] - - await asyncio.sleep(3) - - log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}") - return list() # Failed to get appropriate response within allowed number of retries. - - async def get_top_posts( - self, subreddit: Subreddit, time: str = "all", amount: int = 5, paginate: bool = False - ) -> Union[Embed, list[tuple]]: - """ - Get the top amount of posts for a given subreddit within a specified timeframe. - - A time of "all" will get posts from all time, "day" will get top daily posts and "week" will get the top - weekly posts. - - The amount should be between 0 and 25 as Reddit's JSON requests only provide 25 posts at most. - """ - embed = Embed() - - posts = await self.fetch_posts( - route=f"{subreddit}/top", - amount=amount, - params={"t": time} - ) - if not posts: - embed.title = random.choice(ERROR_REPLIES) - embed.colour = Colour.red() - embed.description = ( - "Sorry! We couldn't find any SFW posts from that subreddit. " - "If this problem persists, please let us know." - ) - - return embed - - if paginate: - return self.build_pagination_pages(posts, paginate=True) - - # Use only starting summary page for #reddit channel posts. - embed.description = self.build_pagination_pages(posts, paginate=False) - embed.colour = Colour.blurple() - return embed - - @loop() - async def auto_poster_loop(self) -> None: - """Post the top 5 posts daily, and the top 5 posts weekly.""" - # once d.py get support for `time` parameter in loop decorator, - # this can be removed and the loop can use the `time=datetime.time.min` parameter - now = datetime.utcnow() - tomorrow = now + timedelta(days=1) - midnight_tomorrow = tomorrow.replace(hour=0, minute=0, second=0) - - await sleep_until(midnight_tomorrow) - - await self.bot.wait_until_guild_available() - if not self.webhook: - await self.bot.fetch_webhook(RedditConfig.webhook) - - if datetime.utcnow().weekday() == 0: - await self.top_weekly_posts() - # if it's a monday send the top weekly posts - - for subreddit in RedditConfig.subreddits: - top_posts = await self.get_top_posts(subreddit=subreddit, time="day") - username = sub_clyde(f"{subreddit} Top Daily Posts") - message = await self.webhook.send(username=username, embed=top_posts, wait=True) - - if message.channel.is_news(): - await message.publish() - - async def top_weekly_posts(self) -> None: - """Post a summary of the top posts.""" - for subreddit in RedditConfig.subreddits: - # Send and pin the new weekly posts. - top_posts = await self.get_top_posts(subreddit=subreddit, time="week") - username = sub_clyde(f"{subreddit} Top Weekly Posts") - message = await self.webhook.send(wait=True, username=username, embed=top_posts) - - if subreddit.lower() == "r/python": - if not self.channel: - log.warning("Failed to get #reddit channel to remove pins in the weekly loop.") - return - - # Remove the oldest pins so that only 12 remain at most. - pins = await self.channel.pins() - - while len(pins) >= 12: - await pins[-1].unpin() - del pins[-1] - - await message.pin() - - if message.channel.is_news(): - await message.publish() - - @group(name="reddit", invoke_without_command=True) - async def reddit_group(self, ctx: Context) -> None: - """View the top posts from various subreddits.""" - await invoke_help_command(ctx) - - @reddit_group.command(name="top") - async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: - """Send the top posts of all time from a given subreddit.""" - async with ctx.typing(): - pages = await self.get_top_posts(subreddit=subreddit, time="all", paginate=True) - - await ctx.send(f"Here are the top {subreddit} posts of all time!") - embed = Embed( - color=Colour.blurple() - ) - - await ImagePaginator.paginate(pages, ctx, embed) - - @reddit_group.command(name="daily") - async def daily_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: - """Send the top posts of today from a given subreddit.""" - async with ctx.typing(): - pages = await self.get_top_posts(subreddit=subreddit, time="day", paginate=True) - - await ctx.send(f"Here are today's top {subreddit} posts!") - embed = Embed( - color=Colour.blurple() - ) - - await ImagePaginator.paginate(pages, ctx, embed) - - @reddit_group.command(name="weekly") - async def weekly_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: - """Send the top posts of this week from a given subreddit.""" - async with ctx.typing(): - pages = await self.get_top_posts(subreddit=subreddit, time="week", paginate=True) - - await ctx.send(f"Here are this week's top {subreddit} posts!") - embed = Embed( - color=Colour.blurple() - ) - - await ImagePaginator.paginate(pages, ctx, embed) - - @has_any_role(*STAFF_ROLES) - @reddit_group.command(name="subreddits", aliases=("subs",)) - async def subreddits_command(self, ctx: Context) -> None: - """Send a paginated embed of all the subreddits we're relaying.""" - embed = Embed() - embed.title = "Relayed subreddits." - embed.colour = Colour.blurple() - - await LinePaginator.paginate( - RedditConfig.subreddits, - ctx, embed, - footer_text="Use the reddit commands along with these to view their posts.", - empty=False, - max_lines=15 - ) - - -def setup(bot: Bot) -> None: - """Load the Reddit cog.""" - if not RedditConfig.secret or not RedditConfig.client_id: - log.error("Credentials not provided, cog not loaded.") - return - bot.add_cog(Reddit(bot)) diff --git a/bot/exts/evergreen/stackoverflow.py b/bot/exts/evergreen/stackoverflow.py deleted file mode 100644 index 64455e33..00000000 --- a/bot/exts/evergreen/stackoverflow.py +++ /dev/null @@ -1,88 +0,0 @@ -import logging -from html import unescape -from urllib.parse import quote_plus - -from discord import Embed, HTTPException -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Colours, Emojis - -logger = logging.getLogger(__name__) - -BASE_URL = "https://api.stackexchange.com/2.2/search/advanced" -SO_PARAMS = { - "order": "desc", - "sort": "activity", - "site": "stackoverflow" -} -SEARCH_URL = "https://stackoverflow.com/search?q={query}" -ERR_EMBED = Embed( - title="Error in fetching results from Stackoverflow", - description=( - "Sorry, there was en error while trying to fetch data from the Stackoverflow website. Please try again in some " - "time. If this issue persists, please contact the staff or send a message in #dev-contrib." - ), - color=Colours.soft_red -) - - -class Stackoverflow(commands.Cog): - """Contains command to interact with stackoverflow from discord.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @commands.command(aliases=["so"]) - @commands.cooldown(1, 15, commands.cooldowns.BucketType.user) - async def stackoverflow(self, ctx: commands.Context, *, search_query: str) -> None: - """Sends the top 5 results of a search query from stackoverflow.""" - params = SO_PARAMS | {"q": search_query} - async with self.bot.http_session.get(url=BASE_URL, params=params) as response: - if response.status == 200: - data = await response.json() - else: - logger.error(f'Status code is not 200, it is {response.status}') - await ctx.send(embed=ERR_EMBED) - return - if not data['items']: - no_search_result = Embed( - title=f"No search results found for {search_query}", - color=Colours.soft_red - ) - await ctx.send(embed=no_search_result) - return - - top5 = data["items"][:5] - encoded_search_query = quote_plus(search_query) - embed = Embed( - title="Search results - Stackoverflow", - url=SEARCH_URL.format(query=encoded_search_query), - description=f"Here are the top {len(top5)} results:", - color=Colours.orange - ) - for item in top5: - embed.add_field( - name=unescape(item['title']), - value=( - f"[{Emojis.reddit_upvote} {item['score']} " - f"{Emojis.stackoverflow_views} {item['view_count']} " - f"{Emojis.reddit_comments} {item['answer_count']} " - f"{Emojis.stackoverflow_tag} {', '.join(item['tags'][:3])}]" - f"({item['link']})" - ), - inline=False) - embed.set_footer(text="View the original link for more results.") - try: - await ctx.send(embed=embed) - except HTTPException: - search_query_too_long = Embed( - title="Your search query is too long, please try shortening your search query", - color=Colours.soft_red - ) - await ctx.send(embed=search_query_too_long) - - -def setup(bot: Bot) -> None: - """Load the Stackoverflow Cog.""" - bot.add_cog(Stackoverflow(bot)) diff --git a/bot/exts/evergreen/timed.py b/bot/exts/evergreen/timed.py deleted file mode 100644 index 2ea6b419..00000000 --- a/bot/exts/evergreen/timed.py +++ /dev/null @@ -1,48 +0,0 @@ -from copy import copy -from time import perf_counter - -from discord import Message -from discord.ext import commands - -from bot.bot import Bot - - -class TimedCommands(commands.Cog): - """Time the command execution of a command.""" - - @staticmethod - async def create_execution_context(ctx: commands.Context, command: str) -> commands.Context: - """Get a new execution context for a command.""" - msg: Message = copy(ctx.message) - msg.content = f"{ctx.prefix}{command}" - - return await ctx.bot.get_context(msg) - - @commands.command(name="timed", aliases=("time", "t")) - async def timed(self, ctx: commands.Context, *, command: str) -> None: - """Time the command execution of a command.""" - new_ctx = await self.create_execution_context(ctx, command) - - ctx.subcontext = new_ctx - - if not ctx.subcontext.command: - help_command = f"{ctx.prefix}help" - error = f"The command you are trying to time doesn't exist. Use `{help_command}` for a list of commands." - - await ctx.send(error) - return - - if new_ctx.command.qualified_name == "timed": - await ctx.send("You are not allowed to time the execution of the `timed` command.") - return - - t_start = perf_counter() - await new_ctx.command.invoke(new_ctx) - t_end = perf_counter() - - await ctx.send(f"Command execution for `{new_ctx.command}` finished in {(t_end - t_start):.4f} seconds.") - - -def setup(bot: Bot) -> None: - """Load the Timed cog.""" - bot.add_cog(TimedCommands()) diff --git a/bot/exts/evergreen/wikipedia.py b/bot/exts/evergreen/wikipedia.py deleted file mode 100644 index eccc1f8c..00000000 --- a/bot/exts/evergreen/wikipedia.py +++ /dev/null @@ -1,100 +0,0 @@ -import logging -import re -from datetime import datetime -from html import unescape - -from discord import Color, Embed, TextChannel -from discord.ext import commands - -from bot.bot import Bot -from bot.utils import LinePaginator -from bot.utils.exceptions import APIError - -log = logging.getLogger(__name__) - -SEARCH_API = ( - "https://en.wikipedia.org/w/api.php" -) -WIKI_PARAMS = { - "action": "query", - "list": "search", - "prop": "info", - "inprop": "url", - "utf8": "", - "format": "json", - "origin": "*", - -} -WIKI_THUMBNAIL = ( - "https://upload.wikimedia.org/wikipedia/en/thumb/8/80/Wikipedia-logo-v2.svg" - "/330px-Wikipedia-logo-v2.svg.png" -) -WIKI_SNIPPET_REGEX = r"(|<[^>]*>)" -WIKI_SEARCH_RESULT = ( - "**[{name}]({url})**\n" - "{description}\n" -) - - -class WikipediaSearch(commands.Cog): - """Get info from wikipedia.""" - - def __init__(self, bot: Bot): - self.bot = bot - - async def wiki_request(self, channel: TextChannel, search: str) -> list[str]: - """Search wikipedia search string and return formatted first 10 pages found.""" - params = WIKI_PARAMS | {"srlimit": 10, "srsearch": search} - async with self.bot.http_session.get(url=SEARCH_API, params=params) as resp: - if resp.status != 200: - log.info(f"Unexpected response `{resp.status}` while searching wikipedia for `{search}`") - raise APIError("Wikipedia API", resp.status) - - raw_data = await resp.json() - - if not raw_data.get("query"): - if error := raw_data.get("errors"): - log.error(f"There was an error while communicating with the Wikipedia API: {error}") - raise APIError("Wikipedia API", resp.status, error) - - lines = [] - if raw_data["query"]["searchinfo"]["totalhits"]: - for article in raw_data["query"]["search"]: - line = WIKI_SEARCH_RESULT.format( - name=article["title"], - description=unescape( - re.sub( - WIKI_SNIPPET_REGEX, "", article["snippet"] - ) - ), - url=f"https://en.wikipedia.org/?curid={article['pageid']}" - ) - lines.append(line) - - return lines - - @commands.cooldown(1, 10, commands.BucketType.user) - @commands.command(name="wikipedia", aliases=("wiki",)) - async def wikipedia_search_command(self, ctx: commands.Context, *, search: str) -> None: - """Sends paginated top 10 results of Wikipedia search..""" - contents = await self.wiki_request(ctx.channel, search) - - if contents: - embed = Embed( - title="Wikipedia Search Results", - colour=Color.blurple() - ) - embed.set_thumbnail(url=WIKI_THUMBNAIL) - embed.timestamp = datetime.utcnow() - await LinePaginator.paginate( - contents, ctx, embed - ) - else: - await ctx.send( - "Sorry, we could not find a wikipedia article using that search term." - ) - - -def setup(bot: Bot) -> None: - """Load the WikipediaSearch cog.""" - bot.add_cog(WikipediaSearch(bot)) diff --git a/bot/exts/evergreen/wolfram.py b/bot/exts/evergreen/wolfram.py deleted file mode 100644 index 9a26e545..00000000 --- a/bot/exts/evergreen/wolfram.py +++ /dev/null @@ -1,293 +0,0 @@ -import logging -from io import BytesIO -from typing import Callable, Optional -from urllib.parse import urlencode - -import arrow -import discord -from discord import Embed -from discord.ext import commands -from discord.ext.commands import BucketType, Cog, Context, check, group - -from bot.bot import Bot -from bot.constants import Colours, STAFF_ROLES, Wolfram -from bot.utils.pagination import ImagePaginator - -log = logging.getLogger(__name__) - -APPID = Wolfram.key -DEFAULT_OUTPUT_FORMAT = "JSON" -QUERY = "http://api.wolframalpha.com/v2/{request}" -WOLF_IMAGE = "https://www.symbols.com/gi.php?type=1&id=2886&i=1" - -MAX_PODS = 20 - -# Allows for 10 wolfram calls pr user pr day -usercd = commands.CooldownMapping.from_cooldown(Wolfram.user_limit_day, 60 * 60 * 24, BucketType.user) - -# Allows for max api requests / days in month per day for the entire guild (Temporary) -guildcd = commands.CooldownMapping.from_cooldown(Wolfram.guild_limit_day, 60 * 60 * 24, BucketType.guild) - - -async def send_embed( - ctx: Context, - message_txt: str, - colour: int = Colours.soft_red, - footer: str = None, - img_url: str = None, - f: discord.File = None -) -> None: - """Generate & send a response embed with Wolfram as the author.""" - embed = Embed(colour=colour) - embed.description = message_txt - embed.set_author( - name="Wolfram Alpha", - icon_url=WOLF_IMAGE, - url="https://www.wolframalpha.com/" - ) - if footer: - embed.set_footer(text=footer) - - if img_url: - embed.set_image(url=img_url) - - await ctx.send(embed=embed, file=f) - - -def custom_cooldown(*ignore: int) -> Callable: - """ - Implement per-user and per-guild cooldowns for requests to the Wolfram API. - - A list of roles may be provided to ignore the per-user cooldown. - """ - async def predicate(ctx: Context) -> bool: - if ctx.invoked_with == "help": - # if the invoked command is help we don't want to increase the ratelimits since it's not actually - # invoking the command/making a request, so instead just check if the user/guild are on cooldown. - guild_cooldown = not guildcd.get_bucket(ctx.message).get_tokens() == 0 # if guild is on cooldown - # check the message is in a guild, and check user bucket if user is not ignored - if ctx.guild and not any(r.id in ignore for r in ctx.author.roles): - return guild_cooldown and not usercd.get_bucket(ctx.message).get_tokens() == 0 - return guild_cooldown - - user_bucket = usercd.get_bucket(ctx.message) - - if all(role.id not in ignore for role in ctx.author.roles): - user_rate = user_bucket.update_rate_limit() - - if user_rate: - # Can't use api; cause: member limit - cooldown = arrow.utcnow().shift(seconds=int(user_rate)).humanize(only_distance=True) - message = ( - "You've used up your limit for Wolfram|Alpha requests.\n" - f"Cooldown: {cooldown}" - ) - await send_embed(ctx, message) - return False - - guild_bucket = guildcd.get_bucket(ctx.message) - guild_rate = guild_bucket.update_rate_limit() - - # Repr has a token attribute to read requests left - log.debug(guild_bucket) - - if guild_rate: - # Can't use api; cause: guild limit - message = ( - "The max limit of requests for the server has been reached for today.\n" - f"Cooldown: {int(guild_rate)}" - ) - await send_embed(ctx, message) - return False - - return True - - return check(predicate) - - -async def get_pod_pages(ctx: Context, bot: Bot, query: str) -> Optional[list[tuple[str, str]]]: - """Get the Wolfram API pod pages for the provided query.""" - async with ctx.typing(): - params = { - "input": query, - "appid": APPID, - "output": DEFAULT_OUTPUT_FORMAT, - "format": "image,plaintext", - "location": "the moon", - "latlong": "0.0,0.0", - "ip": "1.1.1.1" - } - request_url = QUERY.format(request="query") - - async with bot.http_session.get(url=request_url, params=params) as response: - json = await response.json(content_type="text/plain") - - result = json["queryresult"] - log_full_url = f"{request_url}?{urlencode(params)}" - if result["error"]: - # API key not set up correctly - if result["error"]["msg"] == "Invalid appid": - message = "Wolfram API key is invalid or missing." - log.warning( - "API key seems to be missing, or invalid when " - f"processing a wolfram request: {log_full_url}, Response: {json}" - ) - await send_embed(ctx, message) - return None - - message = "Something went wrong internally with your request, please notify staff!" - log.warning(f"Something went wrong getting a response from wolfram: {log_full_url}, Response: {json}") - await send_embed(ctx, message) - return None - - if not result["success"]: - message = f"I couldn't find anything for {query}." - await send_embed(ctx, message) - return None - - if not result["numpods"]: - message = "Could not find any results." - await send_embed(ctx, message) - return None - - pods = result["pods"] - pages = [] - for pod in pods[:MAX_PODS]: - subs = pod.get("subpods") - - for sub in subs: - title = sub.get("title") or sub.get("plaintext") or sub.get("id", "") - img = sub["img"]["src"] - pages.append((title, img)) - return pages - - -class Wolfram(Cog): - """Commands for interacting with the Wolfram|Alpha API.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @group(name="wolfram", aliases=("wolf", "wa"), invoke_without_command=True) - @custom_cooldown(*STAFF_ROLES) - async def wolfram_command(self, ctx: Context, *, query: str) -> None: - """Requests all answers on a single image, sends an image of all related pods.""" - params = { - "i": query, - "appid": APPID, - "location": "the moon", - "latlong": "0.0,0.0", - "ip": "1.1.1.1" - } - request_url = QUERY.format(request="simple") - - # Give feedback that the bot is working. - async with ctx.typing(): - async with self.bot.http_session.get(url=request_url, params=params) as response: - status = response.status - image_bytes = await response.read() - - f = discord.File(BytesIO(image_bytes), filename="image.png") - image_url = "attachment://image.png" - - if status == 501: - message = "Failed to get response." - footer = "" - color = Colours.soft_red - elif status == 400: - message = "No input found." - footer = "" - color = Colours.soft_red - elif status == 403: - message = "Wolfram API key is invalid or missing." - footer = "" - color = Colours.soft_red - else: - message = "" - footer = "View original for a bigger picture." - color = Colours.soft_orange - - # Sends a "blank" embed if no request is received, unsure how to fix - await send_embed(ctx, message, color, footer=footer, img_url=image_url, f=f) - - @wolfram_command.command(name="page", aliases=("pa", "p")) - @custom_cooldown(*STAFF_ROLES) - async def wolfram_page_command(self, ctx: Context, *, query: str) -> None: - """ - Requests a drawn image of given query. - - Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc. - """ - pages = await get_pod_pages(ctx, self.bot, query) - - if not pages: - return - - embed = Embed() - embed.set_author( - name="Wolfram Alpha", - icon_url=WOLF_IMAGE, - url="https://www.wolframalpha.com/" - ) - embed.colour = Colours.soft_orange - - await ImagePaginator.paginate(pages, ctx, embed) - - @wolfram_command.command(name="cut", aliases=("c",)) - @custom_cooldown(*STAFF_ROLES) - async def wolfram_cut_command(self, ctx: Context, *, query: str) -> None: - """ - Requests a drawn image of given query. - - Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc. - """ - pages = await get_pod_pages(ctx, self.bot, query) - - if not pages: - return - - if len(pages) >= 2: - page = pages[1] - else: - page = pages[0] - - await send_embed(ctx, page[0], colour=Colours.soft_orange, img_url=page[1]) - - @wolfram_command.command(name="short", aliases=("sh", "s")) - @custom_cooldown(*STAFF_ROLES) - async def wolfram_short_command(self, ctx: Context, *, query: str) -> None: - """Requests an answer to a simple question.""" - params = { - "i": query, - "appid": APPID, - "location": "the moon", - "latlong": "0.0,0.0", - "ip": "1.1.1.1" - } - request_url = QUERY.format(request="result") - - # Give feedback that the bot is working. - async with ctx.typing(): - async with self.bot.http_session.get(url=request_url, params=params) as response: - status = response.status - response_text = await response.text() - - if status == 501: - message = "Failed to get response." - color = Colours.soft_red - elif status == 400: - message = "No input found." - color = Colours.soft_red - elif response_text == "Error 1: Invalid appid.": - message = "Wolfram API key is invalid or missing." - color = Colours.soft_red - else: - message = response_text - color = Colours.soft_orange - - await send_embed(ctx, message, color) - - -def setup(bot: Bot) -> None: - """Load the Wolfram cog.""" - bot.add_cog(Wolfram(bot)) diff --git a/bot/exts/utilities/__init__.py b/bot/exts/utilities/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bot/exts/utilities/bookmark.py b/bot/exts/utilities/bookmark.py new file mode 100644 index 00000000..a91ef1c0 --- /dev/null +++ b/bot/exts/utilities/bookmark.py @@ -0,0 +1,153 @@ +import asyncio +import logging +import random +from typing import Optional + +import discord +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Categories, Colours, ERROR_REPLIES, Icons, WHITELISTED_CHANNELS +from bot.utils.converters import WrappedMessageConverter +from bot.utils.decorators import whitelist_override + +log = logging.getLogger(__name__) + +# Number of seconds to wait for other users to bookmark the same message +TIMEOUT = 120 +BOOKMARK_EMOJI = "📌" +WHITELISTED_CATEGORIES = (Categories.help_in_use,) + + +class Bookmark(commands.Cog): + """Creates personal bookmarks by relaying a message link to the user's DMs.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @staticmethod + def build_bookmark_dm(target_message: discord.Message, title: str) -> discord.Embed: + """Build the embed to DM the bookmark requester.""" + embed = discord.Embed( + title=title, + description=target_message.content, + colour=Colours.soft_green + ) + 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.display_avatar.url) + embed.set_thumbnail(url=Icons.bookmark) + + return embed + + @staticmethod + def build_error_embed(user: discord.Member) -> discord.Embed: + """Builds an error embed for when a bookmark requester has DMs disabled.""" + return discord.Embed( + title=random.choice(ERROR_REPLIES), + description=f"{user.mention}, please enable your DMs to receive the bookmark.", + colour=Colours.soft_red + ) + + async def action_bookmark( + self, + channel: discord.TextChannel, + user: discord.Member, + target_message: discord.Message, + title: str + ) -> None: + """Sends the bookmark DM, or sends an error embed when a user bookmarks a message.""" + try: + embed = self.build_bookmark_dm(target_message, title) + await user.send(embed=embed) + except discord.Forbidden: + error_embed = self.build_error_embed(user) + await channel.send(embed=error_embed) + else: + log.info(f"{user} bookmarked {target_message.jump_url} with title '{title}'") + + @staticmethod + async def send_reaction_embed( + channel: discord.TextChannel, + target_message: discord.Message + ) -> discord.Message: + """Sends an embed, with a reaction, so users can react to bookmark the message too.""" + message = await channel.send( + embed=discord.Embed( + description=( + f"React with {BOOKMARK_EMOJI} to be sent your very own bookmark to " + f"[this message]({target_message.jump_url})." + ), + colour=Colours.soft_green + ) + ) + + await message.add_reaction(BOOKMARK_EMOJI) + return message + + @whitelist_override(channels=WHITELISTED_CHANNELS, categories=WHITELISTED_CATEGORIES) + @commands.command(name="bookmark", aliases=("bm", "pin")) + async def bookmark( + self, + ctx: commands.Context, + target_message: Optional[WrappedMessageConverter], + *, + title: str = "Bookmark" + ) -> None: + """Send the author a link to `target_message` via DMs.""" + if not target_message: + if not ctx.message.reference: + raise commands.UserInputError("You must either provide a valid message to bookmark, or reply to one.") + target_message = ctx.message.reference.resolved + + # Prevent users from bookmarking a message in a channel they don't have access to + permissions = target_message.channel.permissions_for(ctx.author) + 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 + + def event_check(reaction: discord.Reaction, user: discord.Member) -> bool: + """Make sure that this reaction is what we want to operate on.""" + return ( + # Conditions for a successful pagination: + all(( + # Reaction is on this message + reaction.message.id == reaction_message.id, + # User has not already bookmarked this message + user.id not in bookmarked_users, + # Reaction is the `BOOKMARK_EMOJI` emoji + str(reaction.emoji) == BOOKMARK_EMOJI, + # Reaction was not made by the Bot + user.id != self.bot.user.id + )) + ) + await self.action_bookmark(ctx.channel, ctx.author, target_message, title) + + # Keep track of who has already bookmarked, so users can't spam reactions and cause loads of DMs + bookmarked_users = [ctx.author.id] + reaction_message = await self.send_reaction_embed(ctx.channel, target_message) + + while True: + try: + _, user = await self.bot.wait_for("reaction_add", timeout=TIMEOUT, check=event_check) + except asyncio.TimeoutError: + log.debug("Timed out waiting for a reaction") + break + log.trace(f"{user} has successfully bookmarked from a reaction, attempting to DM them.") + await self.action_bookmark(ctx.channel, user, target_message, title) + bookmarked_users.append(user.id) + + await reaction_message.delete() + + +def setup(bot: Bot) -> None: + """Load the Bookmark cog.""" + bot.add_cog(Bookmark(bot)) diff --git a/bot/exts/utilities/cheatsheet.py b/bot/exts/utilities/cheatsheet.py new file mode 100644 index 00000000..33d29f67 --- /dev/null +++ b/bot/exts/utilities/cheatsheet.py @@ -0,0 +1,112 @@ +import random +import re +from typing import Union +from urllib.parse import quote_plus + +from discord import Embed +from discord.ext import commands +from discord.ext.commands import BucketType, Context + +from bot import constants +from bot.bot import Bot +from bot.constants import Categories, Channels, Colours, ERROR_REPLIES +from bot.utils.decorators import whitelist_override + +ERROR_MESSAGE = f""" +Unknown cheat sheet. Please try to reformulate your query. + +**Examples**: +```md +{constants.Client.prefix}cht read json +{constants.Client.prefix}cht hello world +{constants.Client.prefix}cht lambda +``` +If the problem persists send a message in <#{Channels.dev_contrib}> +""" + +URL = "https://cheat.sh/python/{search}" +ESCAPE_TT = str.maketrans({"`": "\\`"}) +ANSI_RE = re.compile(r"\x1b\[.*?m") +# We need to pass headers as curl otherwise it would default to aiohttp which would return raw html. +HEADERS = {"User-Agent": "curl/7.68.0"} + + +class CheatSheet(commands.Cog): + """Commands that sends a result of a cht.sh search in code blocks.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @staticmethod + def fmt_error_embed() -> Embed: + """ + Format the Error Embed. + + If the cht.sh search returned 404, overwrite it to send a custom error embed. + link -> https://github.com/chubin/cheat.sh/issues/198 + """ + embed = Embed( + title=random.choice(ERROR_REPLIES), + description=ERROR_MESSAGE, + colour=Colours.soft_red + ) + return embed + + def result_fmt(self, url: str, body_text: str) -> tuple[bool, Union[str, Embed]]: + """Format Result.""" + if body_text.startswith("# 404 NOT FOUND"): + embed = self.fmt_error_embed() + return True, embed + + body_space = min(1986 - len(url), 1000) + + if len(body_text) > body_space: + description = ( + f"**Result Of cht.sh**\n" + f"```python\n{body_text[:body_space]}\n" + f"... (truncated - too many lines)\n```\n" + f"Full results: {url} " + ) + else: + description = ( + f"**Result Of cht.sh**\n" + f"```python\n{body_text}\n```\n" + f"{url}" + ) + return False, description + + @commands.command( + name="cheat", + aliases=("cht.sh", "cheatsheet", "cheat-sheet", "cht"), + ) + @commands.cooldown(1, 10, BucketType.user) + @whitelist_override(categories=[Categories.help_in_use]) + async def cheat_sheet(self, ctx: Context, *search_terms: str) -> None: + """ + Search cheat.sh. + + Gets a post from https://cheat.sh/python/ by default. + Usage: + --> .cht read json + """ + async with ctx.typing(): + search_string = quote_plus(" ".join(search_terms)) + + async with self.bot.http_session.get( + URL.format(search=search_string), headers=HEADERS + ) as response: + result = ANSI_RE.sub("", await response.text()).translate(ESCAPE_TT) + + is_embed, description = self.result_fmt( + URL.format(search=search_string), + result + ) + if is_embed: + await ctx.send(embed=description) + else: + await ctx.send(content=description) + + +def setup(bot: Bot) -> None: + """Load the CheatSheet cog.""" + bot.add_cog(CheatSheet(bot)) diff --git a/bot/exts/utilities/conversationstarters.py b/bot/exts/utilities/conversationstarters.py new file mode 100644 index 00000000..dd537022 --- /dev/null +++ b/bot/exts/utilities/conversationstarters.py @@ -0,0 +1,69 @@ +from pathlib import Path + +import yaml +from discord import Color, Embed +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import WHITELISTED_CHANNELS +from bot.utils.decorators import whitelist_override +from bot.utils.randomization import RandomCycle + +SUGGESTION_FORM = "https://forms.gle/zw6kkJqv8U43Nfjg9" + +with Path("bot/resources/utilities/starter.yaml").open("r", encoding="utf8") as f: + STARTERS = yaml.load(f, Loader=yaml.FullLoader) + +with Path("bot/resources/utilities/py_topics.yaml").open("r", encoding="utf8") as f: + # First ID is #python-general and the rest are top to bottom categories of Topical Chat/Help. + PY_TOPICS = yaml.load(f, Loader=yaml.FullLoader) + + # Removing `None` from lists of topics, if not a list, it is changed to an empty one. + PY_TOPICS = {k: [i for i in v if i] if isinstance(v, list) else [] for k, v in PY_TOPICS.items()} + + # All the allowed channels that the ".topic" command is allowed to be executed in. + ALL_ALLOWED_CHANNELS = list(PY_TOPICS.keys()) + list(WHITELISTED_CHANNELS) + +# Putting all topics into one dictionary and shuffling lists to reduce same-topic repetitions. +ALL_TOPICS = {"default": STARTERS, **PY_TOPICS} +TOPICS = { + channel: RandomCycle(topics or ["No topics found for this channel."]) + for channel, topics in ALL_TOPICS.items() +} + + +class ConvoStarters(commands.Cog): + """General conversation topics.""" + + @commands.command() + @whitelist_override(channels=ALL_ALLOWED_CHANNELS) + async def topic(self, ctx: commands.Context) -> None: + """ + Responds with a random topic to start a conversation. + + If in a Python channel, a python-related topic will be given. + + Otherwise, a random conversation topic will be received by the user. + """ + # No matter what, the form will be shown. + embed = Embed(description=f"Suggest more topics [here]({SUGGESTION_FORM})!", color=Color.blurple()) + + try: + # Fetching topics. + channel_topics = TOPICS[ctx.channel.id] + + # If the channel isn't Python-related. + except KeyError: + embed.title = f"**{next(TOPICS['default'])}**" + + # If the channel ID doesn't have any topics. + else: + embed.title = f"**{next(channel_topics)}**" + + finally: + await ctx.send(embed=embed) + + +def setup(bot: Bot) -> None: + """Load the ConvoStarters cog.""" + bot.add_cog(ConvoStarters()) diff --git a/bot/exts/utilities/emoji.py b/bot/exts/utilities/emoji.py new file mode 100644 index 00000000..55d6b8e9 --- /dev/null +++ b/bot/exts/utilities/emoji.py @@ -0,0 +1,123 @@ +import logging +import random +import textwrap +from collections import defaultdict +from datetime import datetime +from typing import Optional + +from discord import Color, Embed, Emoji +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Colours, ERROR_REPLIES +from bot.utils.extensions import invoke_help_command +from bot.utils.pagination import LinePaginator +from bot.utils.time import time_since + +log = logging.getLogger(__name__) + + +class Emojis(commands.Cog): + """A collection of commands related to emojis in the server.""" + + @staticmethod + def embed_builder(emoji: dict) -> tuple[Embed, list[str]]: + """Generates an embed with the emoji names and count.""" + embed = Embed( + color=Colours.orange, + title="Emoji Count", + timestamp=datetime.utcnow() + ) + msg = [] + + if len(emoji) == 1: + for category_name, category_emojis in emoji.items(): + if len(category_emojis) == 1: + msg.append(f"There is **{len(category_emojis)}** emoji in the **{category_name}** category.") + else: + msg.append(f"There are **{len(category_emojis)}** emojis in the **{category_name}** category.") + embed.set_thumbnail(url=random.choice(category_emojis).url) + + else: + for category_name, category_emojis in emoji.items(): + emoji_choice = random.choice(category_emojis) + if len(category_emojis) > 1: + emoji_info = f"There are **{len(category_emojis)}** emojis in the **{category_name}** category." + else: + emoji_info = f"There is **{len(category_emojis)}** emoji in the **{category_name}** category." + if emoji_choice.animated: + msg.append(f" {emoji_info}") + else: + msg.append(f"<:{emoji_choice.name}:{emoji_choice.id}> {emoji_info}") + return embed, msg + + @staticmethod + def generate_invalid_embed(emojis: list[Emoji]) -> tuple[Embed, list[str]]: + """Generates error embed for invalid emoji categories.""" + embed = Embed( + color=Colours.soft_red, + title=random.choice(ERROR_REPLIES) + ) + msg = [] + + emoji_dict = defaultdict(list) + for emoji in emojis: + emoji_dict[emoji.name.split("_")[0]].append(emoji) + + error_comp = ", ".join(emoji_dict) + msg.append(f"These are the valid emoji categories:\n```\n{error_comp}\n```") + return embed, msg + + @commands.group(name="emoji", invoke_without_command=True) + async def emoji_group(self, ctx: commands.Context, emoji: Optional[Emoji]) -> None: + """A group of commands related to emojis.""" + if emoji is not None: + await ctx.invoke(self.info_command, emoji) + else: + await invoke_help_command(ctx) + + @emoji_group.command(name="count", aliases=("c",)) + async def count_command(self, ctx: commands.Context, *, category_query: str = None) -> None: + """Returns embed with emoji category and info given by the user.""" + emoji_dict = defaultdict(list) + + if not ctx.guild.emojis: + await ctx.send("No emojis found.") + return + log.trace(f"Emoji Category {'' if category_query else 'not '}provided by the user.") + for emoji in ctx.guild.emojis: + emoji_category = emoji.name.split("_")[0] + + if category_query is not None and emoji_category not in category_query: + continue + + emoji_dict[emoji_category].append(emoji) + + if not emoji_dict: + log.trace("Invalid name provided by the user") + embed, msg = self.generate_invalid_embed(ctx.guild.emojis) + else: + embed, msg = self.embed_builder(emoji_dict) + await LinePaginator.paginate(lines=msg, ctx=ctx, embed=embed) + + @emoji_group.command(name="info", aliases=("i",)) + async def info_command(self, ctx: commands.Context, emoji: Emoji) -> None: + """Returns relevant information about a Discord Emoji.""" + emoji_information = Embed( + title=f"Emoji Information: {emoji.name}", + description=textwrap.dedent(f""" + **Name:** {emoji.name} + **Created:** {time_since(emoji.created_at, precision="hours")} + **Date:** {datetime.strftime(emoji.created_at, "%d/%m/%Y")} + **ID:** {emoji.id} + """), + color=Color.blurple(), + url=str(emoji.url), + ).set_thumbnail(url=emoji.url) + + await ctx.send(embed=emoji_information) + + +def setup(bot: Bot) -> None: + """Load the Emojis cog.""" + bot.add_cog(Emojis()) diff --git a/bot/exts/utilities/githubinfo.py b/bot/exts/utilities/githubinfo.py new file mode 100644 index 00000000..d00b408d --- /dev/null +++ b/bot/exts/utilities/githubinfo.py @@ -0,0 +1,178 @@ +import logging +import random +from datetime import datetime +from urllib.parse import quote, quote_plus + +import discord +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Colours, NEGATIVE_REPLIES +from bot.exts.core.extensions import invoke_help_command + +log = logging.getLogger(__name__) + +GITHUB_API_URL = "https://api.github.com" + + +class GithubInfo(commands.Cog): + """Fetches info from GitHub.""" + + def __init__(self, bot: Bot): + self.bot = bot + + async def fetch_data(self, url: str) -> dict: + """Retrieve data as a dictionary.""" + async with self.bot.http_session.get(url) as r: + return await r.json() + + @commands.group(name="github", aliases=("gh", "git")) + @commands.cooldown(1, 10, commands.BucketType.user) + async def github_group(self, ctx: commands.Context) -> None: + """Commands for finding information related to GitHub.""" + if ctx.invoked_subcommand is None: + await invoke_help_command(ctx) + + @github_group.command(name="user", aliases=("userinfo",)) + async def github_user_info(self, ctx: commands.Context, username: str) -> None: + """Fetches a user's GitHub information.""" + async with ctx.typing(): + user_data = await self.fetch_data(f"{GITHUB_API_URL}/users/{quote_plus(username)}") + + # User_data will not have a message key if the user exists + if "message" in user_data: + embed = discord.Embed( + title=random.choice(NEGATIVE_REPLIES), + description=f"The profile for `{username}` was not found.", + colour=Colours.soft_red + ) + + await ctx.send(embed=embed) + return + + org_data = await self.fetch_data(user_data["organizations_url"]) + orgs = [f"[{org['login']}](https://github.com/{org['login']})" for org in org_data] + orgs_to_add = " | ".join(orgs) + + gists = user_data["public_gists"] + + # Forming blog link + if user_data["blog"].startswith("http"): # Blog link is complete + blog = user_data["blog"] + elif user_data["blog"]: # Blog exists but the link is not complete + blog = f"https://{user_data['blog']}" + else: + blog = "No website link available" + + embed = discord.Embed( + title=f"`{user_data['login']}`'s GitHub profile info", + description=f"```\n{user_data['bio']}\n```\n" if user_data["bio"] else "", + colour=discord.Colour.blurple(), + url=user_data["html_url"], + timestamp=datetime.strptime(user_data["created_at"], "%Y-%m-%dT%H:%M:%SZ") + ) + embed.set_thumbnail(url=user_data["avatar_url"]) + embed.set_footer(text="Account created at") + + if user_data["type"] == "User": + + embed.add_field( + name="Followers", + value=f"[{user_data['followers']}]({user_data['html_url']}?tab=followers)" + ) + embed.add_field( + name="Following", + value=f"[{user_data['following']}]({user_data['html_url']}?tab=following)" + ) + + embed.add_field( + name="Public repos", + value=f"[{user_data['public_repos']}]({user_data['html_url']}?tab=repositories)" + ) + + if user_data["type"] == "User": + embed.add_field( + name="Gists", + value=f"[{gists}](https://gist.github.com/{quote_plus(username, safe='')})" + ) + + embed.add_field( + name=f"Organization{'s' if len(orgs)!=1 else ''}", + value=orgs_to_add if orgs else "No organizations." + ) + embed.add_field(name="Website", value=blog) + + await ctx.send(embed=embed) + + @github_group.command(name='repository', aliases=('repo',)) + async def github_repo_info(self, ctx: commands.Context, *repo: str) -> None: + """ + Fetches a repositories' GitHub information. + + The repository should look like `user/reponame` or `user reponame`. + """ + repo = "/".join(repo) + if repo.count("/") != 1: + embed = discord.Embed( + title=random.choice(NEGATIVE_REPLIES), + description="The repository should look like `user/reponame` or `user reponame`.", + colour=Colours.soft_red + ) + + await ctx.send(embed=embed) + return + + async with ctx.typing(): + repo_data = await self.fetch_data(f"{GITHUB_API_URL}/repos/{quote(repo)}") + + # There won't be a message key if this repo exists + if "message" in repo_data: + embed = discord.Embed( + title=random.choice(NEGATIVE_REPLIES), + description="The requested repository was not found.", + colour=Colours.soft_red + ) + + await ctx.send(embed=embed) + return + + embed = discord.Embed( + title=repo_data["name"], + description=repo_data["description"], + colour=discord.Colour.blurple(), + url=repo_data["html_url"] + ) + + # If it's a fork, then it will have a parent key + try: + parent = repo_data["parent"] + embed.description += f"\n\nForked from [{parent['full_name']}]({parent['html_url']})" + except KeyError: + log.debug("Repository is not a fork.") + + repo_owner = repo_data["owner"] + + embed.set_author( + name=repo_owner["login"], + url=repo_owner["html_url"], + icon_url=repo_owner["avatar_url"] + ) + + repo_created_at = datetime.strptime(repo_data["created_at"], "%Y-%m-%dT%H:%M:%SZ").strftime("%d/%m/%Y") + last_pushed = datetime.strptime(repo_data["pushed_at"], "%Y-%m-%dT%H:%M:%SZ").strftime("%d/%m/%Y at %H:%M") + + embed.set_footer( + text=( + f"{repo_data['forks_count']} ⑂ " + f"• {repo_data['stargazers_count']} ⭐ " + f"• Created At {repo_created_at} " + f"• Last Commit {last_pushed}" + ) + ) + + await ctx.send(embed=embed) + + +def setup(bot: Bot) -> None: + """Load the GithubInfo cog.""" + bot.add_cog(GithubInfo(bot)) diff --git a/bot/exts/utilities/issues.py b/bot/exts/utilities/issues.py new file mode 100644 index 00000000..8a7ebed0 --- /dev/null +++ b/bot/exts/utilities/issues.py @@ -0,0 +1,275 @@ +import logging +import random +import re +from dataclasses import dataclass +from typing import Optional, Union + +import discord +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import ( + Categories, + Channels, + Colours, + ERROR_REPLIES, + Emojis, + NEGATIVE_REPLIES, + Tokens, + WHITELISTED_CHANNELS +) +from bot.utils.decorators import whitelist_override +from bot.utils.extensions import invoke_help_command + +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!" +} +REQUEST_HEADERS = { + "Accept": "application/vnd.github.v3+json" +} + +REPOSITORY_ENDPOINT = "https://api.github.com/orgs/{org}/repos?per_page=100&type=public" +ISSUE_ENDPOINT = "https://api.github.com/repos/{user}/{repository}/issues/{number}" +PR_ENDPOINT = "https://api.github.com/repos/{user}/{repository}/pulls/{number}" + +if GITHUB_TOKEN := Tokens.github: + REQUEST_HEADERS["Authorization"] = f"token {GITHUB_TOKEN}" + +WHITELISTED_CATEGORIES = ( + Categories.development, Categories.devprojects, Categories.media, Categories.staff +) + +CODE_BLOCK_RE = re.compile( + r"^`([^`\n]+)`" # Inline codeblock + r"|```(.+?)```", # Multiline codeblock + re.DOTALL | re.MULTILINE +) + +# Maximum number of issues in one message +MAXIMUM_ISSUES = 5 + +# Regex used when looking for automatic linking in messages +# regex101 of current regex https://regex101.com/r/V2ji8M/6 +AUTOMATIC_REGEX = re.compile( + r"((?P[a-zA-Z0-9][a-zA-Z0-9\-]{1,39})\/)?(?P[\w\-\.]{1,100})#(?P[0-9]+)" +) + + +@dataclass +class FoundIssue: + """Dataclass representing an issue found by the regex.""" + + organisation: Optional[str] + repository: str + number: str + + def __hash__(self) -> int: + return hash((self.organisation, self.repository, self.number)) + + +@dataclass +class FetchError: + """Dataclass representing an error while fetching an issue.""" + + return_code: int + message: str + + +@dataclass +class IssueState: + """Dataclass representing the state of an issue.""" + + repository: str + number: int + url: str + title: str + emoji: str + + +class Issues(commands.Cog): + """Cog that allows users to retrieve issues from GitHub.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.repos = [] + + @staticmethod + def remove_codeblocks(message: str) -> str: + """Remove any codeblock in a message.""" + return re.sub(CODE_BLOCK_RE, "", message) + + async def fetch_issues( + self, + number: int, + repository: str, + user: str + ) -> Union[IssueState, FetchError]: + """ + Retrieve an issue from a GitHub repository. + + Returns IssueState on success, FetchError on failure. + """ + url = ISSUE_ENDPOINT.format(user=user, repository=repository, number=number) + pulls_url = PR_ENDPOINT.format(user=user, repository=repository, number=number) + log.trace(f"Querying GH issues API: {url}") + + async with self.bot.http_session.get(url, headers=REQUEST_HEADERS) as r: + json_data = await r.json() + + if r.status == 403: + if r.headers.get("X-RateLimit-Remaining") == "0": + log.info(f"Ratelimit reached while fetching {url}") + return FetchError(403, "Ratelimit reached, please retry in a few minutes.") + return FetchError(403, "Cannot access issue.") + elif r.status in (404, 410): + return FetchError(r.status, "Issue not found.") + elif r.status != 200: + return FetchError(r.status, "Error while fetching issue.") + + # 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["html_url"]: + if json_data.get("state") == "open": + emoji = Emojis.issue_open + else: + emoji = 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: {pulls_url}") + async with self.bot.http_session.get(pulls_url) as p: + pull_data = await p.json() + if pull_data["draft"]: + emoji = Emojis.pull_request_draft + elif pull_data["state"] == "open": + emoji = Emojis.pull_request_open + # When 'merged_at' is not None, this means that the state of the PR is merged + elif pull_data["merged_at"] is not None: + emoji = Emojis.pull_request_merged + else: + emoji = Emojis.pull_request_closed + + issue_url = json_data.get("html_url") + + return IssueState(repository, number, issue_url, json_data.get("title", ""), emoji) + + @staticmethod + def format_embed( + results: list[Union[IssueState, FetchError]], + user: str, + repository: Optional[str] = None + ) -> discord.Embed: + """Take a list of IssueState or FetchError and format a Discord embed for them.""" + description_list = [] + + for result in results: + if isinstance(result, IssueState): + description_list.append(f"{result.emoji} [{result.title}]({result.url})") + elif isinstance(result, FetchError): + description_list.append(f":x: [{result.return_code}] {result.message}") + + resp = discord.Embed( + colour=Colours.bright_green, + description="\n".join(description_list) + ) + + embed_url = f"https://github.com/{user}/{repository}" if repository else f"https://github.com/{user}" + resp.set_author(name="GitHub", url=embed_url) + return resp + + @whitelist_override(channels=WHITELISTED_CHANNELS, categories=WHITELISTED_CATEGORIES) + @commands.command(aliases=("pr",)) + async def issue( + self, + ctx: commands.Context, + numbers: commands.Greedy[int], + repository: str = "sir-lancebot", + user: str = "python-discord" + ) -> None: + """Command to retrieve issue(s) from a GitHub repository.""" + # Remove duplicates + numbers = set(numbers) + + if len(numbers) > MAXIMUM_ISSUES: + embed = discord.Embed( + title=random.choice(ERROR_REPLIES), + color=Colours.soft_red, + description=f"Too many issues/PRs! (maximum of {MAXIMUM_ISSUES})" + ) + await ctx.send(embed=embed) + await invoke_help_command(ctx) + + results = [await self.fetch_issues(number, repository, user) for number in numbers] + await ctx.send(embed=self.format_embed(results, user, repository)) + + @commands.Cog.listener() + async def on_message(self, message: discord.Message) -> None: + """ + Automatic issue linking. + + Listener to retrieve issue(s) from a GitHub repository using automatic linking if matching /#. + """ + # Ignore bots + if message.author.bot: + return + + issues = [ + FoundIssue(*match.group("org", "repo", "number")) + for match in AUTOMATIC_REGEX.finditer(self.remove_codeblocks(message.content)) + ] + links = [] + + if issues: + # Block this from working in DMs + if not message.guild: + await message.channel.send( + embed=discord.Embed( + title=random.choice(NEGATIVE_REPLIES), + description=( + "You can't retrieve issues from DMs. " + f"Try again in <#{Channels.community_bot_commands}>" + ), + colour=Colours.soft_red + ) + ) + return + + log.trace(f"Found {issues = }") + # Remove duplicates + issues = set(issues) + + if len(issues) > MAXIMUM_ISSUES: + embed = discord.Embed( + title=random.choice(ERROR_REPLIES), + color=Colours.soft_red, + description=f"Too many issues/PRs! (maximum of {MAXIMUM_ISSUES})" + ) + await message.channel.send(embed=embed, delete_after=5) + return + + for repo_issue in issues: + result = await self.fetch_issues( + int(repo_issue.number), + repo_issue.repository, + repo_issue.organisation or "python-discord" + ) + if isinstance(result, IssueState): + links.append(result) + + if not links: + return + + resp = self.format_embed(links, "python-discord") + await message.channel.send(embed=resp) + + +def setup(bot: Bot) -> None: + """Load the Issues cog.""" + bot.add_cog(Issues(bot)) diff --git a/bot/exts/utilities/latex.py b/bot/exts/utilities/latex.py new file mode 100644 index 00000000..36c7e0ab --- /dev/null +++ b/bot/exts/utilities/latex.py @@ -0,0 +1,101 @@ +import asyncio +import hashlib +import pathlib +import re +from concurrent.futures import ThreadPoolExecutor +from io import BytesIO + +import discord +import matplotlib.pyplot as plt +from discord.ext import commands + +from bot.bot import Bot + +# configure fonts and colors for matplotlib +plt.rcParams.update( + { + "font.size": 16, + "mathtext.fontset": "cm", # Computer Modern font set + "mathtext.rm": "serif", + "figure.facecolor": "36393F", # matches Discord's dark mode background color + "text.color": "white", + } +) + +FORMATTED_CODE_REGEX = re.compile( + r"(?P(?P```)|``?)" # code delimiter: 1-3 backticks; (?P=block) only matches if it's a block + r"(?(block)(?:(?P[a-z]+)\n)?)" # if we're in a block, match optional language (only letters plus newline) + r"(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code + r"(?P.*?)" # extract all code inside the markup + r"\s*" # any more whitespace before the end of the code markup + r"(?P=delim)", # match the exact same delimiter from the start again + re.DOTALL | re.IGNORECASE, # "." also matches newlines, case insensitive +) + +CACHE_DIRECTORY = pathlib.Path("_latex_cache") +CACHE_DIRECTORY.mkdir(exist_ok=True) + + +class Latex(commands.Cog): + """Renders latex.""" + + @staticmethod + def _render(text: str, filepath: pathlib.Path) -> BytesIO: + """ + Return the rendered image if latex compiles without errors, otherwise raise a BadArgument Exception. + + Saves rendered image to cache. + """ + fig = plt.figure() + rendered_image = BytesIO() + fig.text(0, 1, text, horizontalalignment="left", verticalalignment="top") + + try: + plt.savefig(rendered_image, bbox_inches="tight", dpi=600) + except ValueError as e: + raise commands.BadArgument(str(e)) + + rendered_image.seek(0) + + with open(filepath, "wb") as f: + f.write(rendered_image.getbuffer()) + + return rendered_image + + @staticmethod + def _prepare_input(text: str) -> str: + text = text.replace(r"\\", "$\n$") # matplotlib uses \n for newlines, not \\ + + if match := FORMATTED_CODE_REGEX.match(text): + return match.group("code") + else: + return text + + @commands.command() + @commands.max_concurrency(1, commands.BucketType.guild, wait=True) + async def latex(self, ctx: commands.Context, *, text: str) -> None: + """Renders the text in latex and sends the image.""" + text = self._prepare_input(text) + query_hash = hashlib.md5(text.encode()).hexdigest() + image_path = CACHE_DIRECTORY.joinpath(f"{query_hash}.png") + async with ctx.typing(): + if image_path.exists(): + await ctx.send(file=discord.File(image_path)) + return + + with ThreadPoolExecutor() as pool: + image = await asyncio.get_running_loop().run_in_executor( + pool, self._render, text, image_path + ) + + await ctx.send(file=discord.File(image, "latex.png")) + + +def setup(bot: Bot) -> None: + """Load the Latex Cog.""" + # As we have resource issues on this cog, + # we have it currently disabled while we fix it. + import logging + logging.info("Latex cog is currently disabled. It won't be loaded.") + return + bot.add_cog(Latex()) diff --git a/bot/exts/utilities/pythonfacts.py b/bot/exts/utilities/pythonfacts.py new file mode 100644 index 00000000..ef190185 --- /dev/null +++ b/bot/exts/utilities/pythonfacts.py @@ -0,0 +1,36 @@ +import itertools + +import discord +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Colours + +with open("bot/resources/utilities/python_facts.txt") as file: + FACTS = itertools.cycle(list(file)) + +COLORS = itertools.cycle([Colours.python_blue, Colours.python_yellow]) +PYFACTS_DISCUSSION = "https://github.com/python-discord/meta/discussions/93" + + +class PythonFacts(commands.Cog): + """Sends a random fun fact about Python.""" + + @commands.command(name="pythonfact", aliases=("pyfact",)) + async def get_python_fact(self, ctx: commands.Context) -> None: + """Sends a Random fun fact about Python.""" + embed = discord.Embed( + title="Python Facts", + description=next(FACTS), + colour=next(COLORS) + ) + embed.add_field( + name="Suggestions", + value=f"Suggest more facts [here!]({PYFACTS_DISCUSSION})" + ) + await ctx.send(embed=embed) + + +def setup(bot: Bot) -> None: + """Load the PythonFacts Cog.""" + bot.add_cog(PythonFacts()) diff --git a/bot/exts/utilities/realpython.py b/bot/exts/utilities/realpython.py new file mode 100644 index 00000000..ef8b2638 --- /dev/null +++ b/bot/exts/utilities/realpython.py @@ -0,0 +1,81 @@ +import logging +from html import unescape +from urllib.parse import quote_plus + +from discord import Embed +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Colours + +logger = logging.getLogger(__name__) + + +API_ROOT = "https://realpython.com/search/api/v1/" +ARTICLE_URL = "https://realpython.com{article_url}" +SEARCH_URL = "https://realpython.com/search?q={user_search}" + + +ERROR_EMBED = Embed( + title="Error while searching Real Python", + description="There was an error while trying to reach Real Python. Please try again shortly.", + color=Colours.soft_red, +) + + +class RealPython(commands.Cog): + """User initiated command to search for a Real Python article.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @commands.command(aliases=["rp"]) + @commands.cooldown(1, 10, commands.cooldowns.BucketType.user) + async def realpython(self, ctx: commands.Context, *, user_search: str) -> None: + """Send 5 articles that match the user's search terms.""" + params = {"q": user_search, "limit": 5, "kind": "article"} + async with self.bot.http_session.get(url=API_ROOT, params=params) as response: + if response.status != 200: + logger.error( + f"Unexpected status code {response.status} from Real Python" + ) + await ctx.send(embed=ERROR_EMBED) + return + + data = await response.json() + + articles = data["results"] + + if len(articles) == 0: + no_articles = Embed( + title=f"No articles found for '{user_search}'", color=Colours.soft_red + ) + await ctx.send(embed=no_articles) + return + + if len(articles) == 1: + article_description = "Here is the result:" + else: + article_description = f"Here are the top {len(articles)} results:" + + article_embed = Embed( + title="Search results - Real Python", + url=SEARCH_URL.format(user_search=quote_plus(user_search)), + description=article_description, + color=Colours.orange, + ) + + for article in articles: + article_embed.add_field( + name=unescape(article["title"]), + value=ARTICLE_URL.format(article_url=article["url"]), + inline=False, + ) + article_embed.set_footer(text="Click the links to go to the articles.") + + await ctx.send(embed=article_embed) + + +def setup(bot: Bot) -> None: + """Load the Real Python Cog.""" + bot.add_cog(RealPython(bot)) diff --git a/bot/exts/utilities/reddit.py b/bot/exts/utilities/reddit.py new file mode 100644 index 00000000..e6cb5337 --- /dev/null +++ b/bot/exts/utilities/reddit.py @@ -0,0 +1,368 @@ +import asyncio +import logging +import random +import textwrap +from collections import namedtuple +from datetime import datetime, timedelta +from typing import Union + +from aiohttp import BasicAuth, ClientError +from discord import Colour, Embed, TextChannel +from discord.ext.commands import Cog, Context, group, has_any_role +from discord.ext.tasks import loop +from discord.utils import escape_markdown, sleep_until + +from bot.bot import Bot +from bot.constants import Channels, ERROR_REPLIES, Emojis, Reddit as RedditConfig, STAFF_ROLES +from bot.utils.converters import Subreddit +from bot.utils.extensions import invoke_help_command +from bot.utils.messages import sub_clyde +from bot.utils.pagination import ImagePaginator, LinePaginator + +log = logging.getLogger(__name__) + +AccessToken = namedtuple("AccessToken", ["token", "expires_at"]) + + +class Reddit(Cog): + """Track subreddit posts and show detailed statistics about them.""" + + HEADERS = {"User-Agent": "python3:python-discord/bot:1.0.0 (by /u/PythonDiscord)"} + URL = "https://www.reddit.com" + OAUTH_URL = "https://oauth.reddit.com" + MAX_RETRIES = 3 + + def __init__(self, bot: Bot): + self.bot = bot + + self.webhook = None + self.access_token = None + self.client_auth = BasicAuth(RedditConfig.client_id, RedditConfig.secret) + + bot.loop.create_task(self.init_reddit_ready()) + self.auto_poster_loop.start() + + def cog_unload(self) -> None: + """Stop the loop task and revoke the access token when the cog is unloaded.""" + self.auto_poster_loop.cancel() + if self.access_token and self.access_token.expires_at > datetime.utcnow(): + asyncio.create_task(self.revoke_access_token()) + + async def init_reddit_ready(self) -> None: + """Sets the reddit webhook when the cog is loaded.""" + await self.bot.wait_until_guild_available() + if not self.webhook: + self.webhook = await self.bot.fetch_webhook(RedditConfig.webhook) + + @property + def channel(self) -> TextChannel: + """Get the #reddit channel object from the bot's cache.""" + return self.bot.get_channel(Channels.reddit) + + def build_pagination_pages(self, posts: list[dict], paginate: bool) -> Union[list[tuple], str]: + """Build embed pages required for Paginator.""" + pages = [] + first_page = "" + for post in posts: + post_page = "" + image_url = "" + + data = post["data"] + + title = textwrap.shorten(data["title"], width=50, placeholder="...") + + # Normal brackets interfere with Markdown. + title = escape_markdown(title).replace("[", "⦋").replace("]", "⦌") + link = self.URL + data["permalink"] + + first_page += f"**[{title.replace('*', '')}]({link})**\n" + + text = data["selftext"] + if text: + text = escape_markdown(text).replace("[", "⦋").replace("]", "⦌") + first_page += textwrap.shorten(text, width=100, placeholder="...") + "\n" + + ups = data["ups"] + comments = data["num_comments"] + author = data["author"] + + content_type = Emojis.reddit_post_text + if data["is_video"] or {"youtube", "youtu.be"}.issubset(set(data["url"].split("."))): + # This means the content type in the post is a video. + content_type = f"{Emojis.reddit_post_video}" + + elif data["url"].endswith(("jpg", "png", "gif")): + # This means the content type in the post is an image. + content_type = f"{Emojis.reddit_post_photo}" + image_url = data["url"] + + first_page += ( + f"{content_type}\u2003{Emojis.reddit_upvote}{ups}\u2003{Emojis.reddit_comments}" + f"\u2002{comments}\u2003{Emojis.reddit_users}{author}\n\n" + ) + + if paginate: + post_page += f"**[{title}]({link})**\n\n" + if text: + post_page += textwrap.shorten(text, width=252, placeholder="...") + "\n\n" + post_page += ( + f"{content_type}\u2003{Emojis.reddit_upvote}{ups}\u2003{Emojis.reddit_comments}\u2002" + f"{comments}\u2003{Emojis.reddit_users}{author}" + ) + + pages.append((post_page, image_url)) + + if not paginate: + # Return the first summery page if pagination is not required + return first_page + + pages.insert(0, (first_page, "")) # Using image paginator, hence settings image url to empty string + return pages + + async def get_access_token(self) -> None: + """ + Get a Reddit API OAuth2 access token and assign it to self.access_token. + + A token is valid for 1 hour. There will be MAX_RETRIES to get a token, after which the cog + will be unloaded and a ClientError raised if retrieval was still unsuccessful. + """ + for i in range(1, self.MAX_RETRIES + 1): + response = await self.bot.http_session.post( + url=f"{self.URL}/api/v1/access_token", + headers=self.HEADERS, + auth=self.client_auth, + data={ + "grant_type": "client_credentials", + "duration": "temporary" + } + ) + + if response.status == 200 and response.content_type == "application/json": + content = await response.json() + expiration = int(content["expires_in"]) - 60 # Subtract 1 minute for leeway. + self.access_token = AccessToken( + token=content["access_token"], + expires_at=datetime.utcnow() + timedelta(seconds=expiration) + ) + + log.debug(f"New token acquired; expires on UTC {self.access_token.expires_at}") + return + else: + log.debug( + f"Failed to get an access token: " + f"status {response.status} & content type {response.content_type}; " + f"retrying ({i}/{self.MAX_RETRIES})" + ) + + await asyncio.sleep(3) + + self.bot.remove_cog(self.qualified_name) + raise ClientError("Authentication with the Reddit API failed. Unloading the cog.") + + async def revoke_access_token(self) -> None: + """ + Revoke the OAuth2 access token for the Reddit API. + + For security reasons, it's good practice to revoke the token when it's no longer being used. + """ + response = await self.bot.http_session.post( + url=f"{self.URL}/api/v1/revoke_token", + headers=self.HEADERS, + auth=self.client_auth, + data={ + "token": self.access_token.token, + "token_type_hint": "access_token" + } + ) + + if response.status in [200, 204] and response.content_type == "application/json": + self.access_token = None + else: + log.warning(f"Unable to revoke access token: status {response.status}.") + + async def fetch_posts(self, route: str, *, amount: int = 25, params: dict = None) -> list[dict]: + """A helper method to fetch a certain amount of Reddit posts at a given route.""" + # Reddit's JSON responses only provide 25 posts at most. + if not 25 >= amount > 0: + raise ValueError("Invalid amount of subreddit posts requested.") + + # Renew the token if necessary. + if not self.access_token or self.access_token.expires_at < datetime.utcnow(): + await self.get_access_token() + + url = f"{self.OAUTH_URL}/{route}" + for _ in range(self.MAX_RETRIES): + response = await self.bot.http_session.get( + url=url, + headers={**self.HEADERS, "Authorization": f"bearer {self.access_token.token}"}, + params=params + ) + if response.status == 200 and response.content_type == 'application/json': + # Got appropriate response - process and return. + content = await response.json() + posts = content["data"]["children"] + + filtered_posts = [post for post in posts if not post["data"]["over_18"]] + + return filtered_posts[:amount] + + await asyncio.sleep(3) + + log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}") + return list() # Failed to get appropriate response within allowed number of retries. + + async def get_top_posts( + self, subreddit: Subreddit, time: str = "all", amount: int = 5, paginate: bool = False + ) -> Union[Embed, list[tuple]]: + """ + Get the top amount of posts for a given subreddit within a specified timeframe. + + A time of "all" will get posts from all time, "day" will get top daily posts and "week" will get the top + weekly posts. + + The amount should be between 0 and 25 as Reddit's JSON requests only provide 25 posts at most. + """ + embed = Embed() + + posts = await self.fetch_posts( + route=f"{subreddit}/top", + amount=amount, + params={"t": time} + ) + if not posts: + embed.title = random.choice(ERROR_REPLIES) + embed.colour = Colour.red() + embed.description = ( + "Sorry! We couldn't find any SFW posts from that subreddit. " + "If this problem persists, please let us know." + ) + + return embed + + if paginate: + return self.build_pagination_pages(posts, paginate=True) + + # Use only starting summary page for #reddit channel posts. + embed.description = self.build_pagination_pages(posts, paginate=False) + embed.colour = Colour.blurple() + return embed + + @loop() + async def auto_poster_loop(self) -> None: + """Post the top 5 posts daily, and the top 5 posts weekly.""" + # once d.py get support for `time` parameter in loop decorator, + # this can be removed and the loop can use the `time=datetime.time.min` parameter + now = datetime.utcnow() + tomorrow = now + timedelta(days=1) + midnight_tomorrow = tomorrow.replace(hour=0, minute=0, second=0) + + await sleep_until(midnight_tomorrow) + + await self.bot.wait_until_guild_available() + if not self.webhook: + await self.bot.fetch_webhook(RedditConfig.webhook) + + if datetime.utcnow().weekday() == 0: + await self.top_weekly_posts() + # if it's a monday send the top weekly posts + + for subreddit in RedditConfig.subreddits: + top_posts = await self.get_top_posts(subreddit=subreddit, time="day") + username = sub_clyde(f"{subreddit} Top Daily Posts") + message = await self.webhook.send(username=username, embed=top_posts, wait=True) + + if message.channel.is_news(): + await message.publish() + + async def top_weekly_posts(self) -> None: + """Post a summary of the top posts.""" + for subreddit in RedditConfig.subreddits: + # Send and pin the new weekly posts. + top_posts = await self.get_top_posts(subreddit=subreddit, time="week") + username = sub_clyde(f"{subreddit} Top Weekly Posts") + message = await self.webhook.send(wait=True, username=username, embed=top_posts) + + if subreddit.lower() == "r/python": + if not self.channel: + log.warning("Failed to get #reddit channel to remove pins in the weekly loop.") + return + + # Remove the oldest pins so that only 12 remain at most. + pins = await self.channel.pins() + + while len(pins) >= 12: + await pins[-1].unpin() + del pins[-1] + + await message.pin() + + if message.channel.is_news(): + await message.publish() + + @group(name="reddit", invoke_without_command=True) + async def reddit_group(self, ctx: Context) -> None: + """View the top posts from various subreddits.""" + await invoke_help_command(ctx) + + @reddit_group.command(name="top") + async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: + """Send the top posts of all time from a given subreddit.""" + async with ctx.typing(): + pages = await self.get_top_posts(subreddit=subreddit, time="all", paginate=True) + + await ctx.send(f"Here are the top {subreddit} posts of all time!") + embed = Embed( + color=Colour.blurple() + ) + + await ImagePaginator.paginate(pages, ctx, embed) + + @reddit_group.command(name="daily") + async def daily_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: + """Send the top posts of today from a given subreddit.""" + async with ctx.typing(): + pages = await self.get_top_posts(subreddit=subreddit, time="day", paginate=True) + + await ctx.send(f"Here are today's top {subreddit} posts!") + embed = Embed( + color=Colour.blurple() + ) + + await ImagePaginator.paginate(pages, ctx, embed) + + @reddit_group.command(name="weekly") + async def weekly_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: + """Send the top posts of this week from a given subreddit.""" + async with ctx.typing(): + pages = await self.get_top_posts(subreddit=subreddit, time="week", paginate=True) + + await ctx.send(f"Here are this week's top {subreddit} posts!") + embed = Embed( + color=Colour.blurple() + ) + + await ImagePaginator.paginate(pages, ctx, embed) + + @has_any_role(*STAFF_ROLES) + @reddit_group.command(name="subreddits", aliases=("subs",)) + async def subreddits_command(self, ctx: Context) -> None: + """Send a paginated embed of all the subreddits we're relaying.""" + embed = Embed() + embed.title = "Relayed subreddits." + embed.colour = Colour.blurple() + + await LinePaginator.paginate( + RedditConfig.subreddits, + ctx, embed, + footer_text="Use the reddit commands along with these to view their posts.", + empty=False, + max_lines=15 + ) + + +def setup(bot: Bot) -> None: + """Load the Reddit cog.""" + if not RedditConfig.secret or not RedditConfig.client_id: + log.error("Credentials not provided, cog not loaded.") + return + bot.add_cog(Reddit(bot)) diff --git a/bot/exts/utilities/stackoverflow.py b/bot/exts/utilities/stackoverflow.py new file mode 100644 index 00000000..64455e33 --- /dev/null +++ b/bot/exts/utilities/stackoverflow.py @@ -0,0 +1,88 @@ +import logging +from html import unescape +from urllib.parse import quote_plus + +from discord import Embed, HTTPException +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Colours, Emojis + +logger = logging.getLogger(__name__) + +BASE_URL = "https://api.stackexchange.com/2.2/search/advanced" +SO_PARAMS = { + "order": "desc", + "sort": "activity", + "site": "stackoverflow" +} +SEARCH_URL = "https://stackoverflow.com/search?q={query}" +ERR_EMBED = Embed( + title="Error in fetching results from Stackoverflow", + description=( + "Sorry, there was en error while trying to fetch data from the Stackoverflow website. Please try again in some " + "time. If this issue persists, please contact the staff or send a message in #dev-contrib." + ), + color=Colours.soft_red +) + + +class Stackoverflow(commands.Cog): + """Contains command to interact with stackoverflow from discord.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @commands.command(aliases=["so"]) + @commands.cooldown(1, 15, commands.cooldowns.BucketType.user) + async def stackoverflow(self, ctx: commands.Context, *, search_query: str) -> None: + """Sends the top 5 results of a search query from stackoverflow.""" + params = SO_PARAMS | {"q": search_query} + async with self.bot.http_session.get(url=BASE_URL, params=params) as response: + if response.status == 200: + data = await response.json() + else: + logger.error(f'Status code is not 200, it is {response.status}') + await ctx.send(embed=ERR_EMBED) + return + if not data['items']: + no_search_result = Embed( + title=f"No search results found for {search_query}", + color=Colours.soft_red + ) + await ctx.send(embed=no_search_result) + return + + top5 = data["items"][:5] + encoded_search_query = quote_plus(search_query) + embed = Embed( + title="Search results - Stackoverflow", + url=SEARCH_URL.format(query=encoded_search_query), + description=f"Here are the top {len(top5)} results:", + color=Colours.orange + ) + for item in top5: + embed.add_field( + name=unescape(item['title']), + value=( + f"[{Emojis.reddit_upvote} {item['score']} " + f"{Emojis.stackoverflow_views} {item['view_count']} " + f"{Emojis.reddit_comments} {item['answer_count']} " + f"{Emojis.stackoverflow_tag} {', '.join(item['tags'][:3])}]" + f"({item['link']})" + ), + inline=False) + embed.set_footer(text="View the original link for more results.") + try: + await ctx.send(embed=embed) + except HTTPException: + search_query_too_long = Embed( + title="Your search query is too long, please try shortening your search query", + color=Colours.soft_red + ) + await ctx.send(embed=search_query_too_long) + + +def setup(bot: Bot) -> None: + """Load the Stackoverflow Cog.""" + bot.add_cog(Stackoverflow(bot)) diff --git a/bot/exts/utilities/timed.py b/bot/exts/utilities/timed.py new file mode 100644 index 00000000..2ea6b419 --- /dev/null +++ b/bot/exts/utilities/timed.py @@ -0,0 +1,48 @@ +from copy import copy +from time import perf_counter + +from discord import Message +from discord.ext import commands + +from bot.bot import Bot + + +class TimedCommands(commands.Cog): + """Time the command execution of a command.""" + + @staticmethod + async def create_execution_context(ctx: commands.Context, command: str) -> commands.Context: + """Get a new execution context for a command.""" + msg: Message = copy(ctx.message) + msg.content = f"{ctx.prefix}{command}" + + return await ctx.bot.get_context(msg) + + @commands.command(name="timed", aliases=("time", "t")) + async def timed(self, ctx: commands.Context, *, command: str) -> None: + """Time the command execution of a command.""" + new_ctx = await self.create_execution_context(ctx, command) + + ctx.subcontext = new_ctx + + if not ctx.subcontext.command: + help_command = f"{ctx.prefix}help" + error = f"The command you are trying to time doesn't exist. Use `{help_command}` for a list of commands." + + await ctx.send(error) + return + + if new_ctx.command.qualified_name == "timed": + await ctx.send("You are not allowed to time the execution of the `timed` command.") + return + + t_start = perf_counter() + await new_ctx.command.invoke(new_ctx) + t_end = perf_counter() + + await ctx.send(f"Command execution for `{new_ctx.command}` finished in {(t_end - t_start):.4f} seconds.") + + +def setup(bot: Bot) -> None: + """Load the Timed cog.""" + bot.add_cog(TimedCommands()) diff --git a/bot/exts/utilities/wikipedia.py b/bot/exts/utilities/wikipedia.py new file mode 100644 index 00000000..eccc1f8c --- /dev/null +++ b/bot/exts/utilities/wikipedia.py @@ -0,0 +1,100 @@ +import logging +import re +from datetime import datetime +from html import unescape + +from discord import Color, Embed, TextChannel +from discord.ext import commands + +from bot.bot import Bot +from bot.utils import LinePaginator +from bot.utils.exceptions import APIError + +log = logging.getLogger(__name__) + +SEARCH_API = ( + "https://en.wikipedia.org/w/api.php" +) +WIKI_PARAMS = { + "action": "query", + "list": "search", + "prop": "info", + "inprop": "url", + "utf8": "", + "format": "json", + "origin": "*", + +} +WIKI_THUMBNAIL = ( + "https://upload.wikimedia.org/wikipedia/en/thumb/8/80/Wikipedia-logo-v2.svg" + "/330px-Wikipedia-logo-v2.svg.png" +) +WIKI_SNIPPET_REGEX = r"(|<[^>]*>)" +WIKI_SEARCH_RESULT = ( + "**[{name}]({url})**\n" + "{description}\n" +) + + +class WikipediaSearch(commands.Cog): + """Get info from wikipedia.""" + + def __init__(self, bot: Bot): + self.bot = bot + + async def wiki_request(self, channel: TextChannel, search: str) -> list[str]: + """Search wikipedia search string and return formatted first 10 pages found.""" + params = WIKI_PARAMS | {"srlimit": 10, "srsearch": search} + async with self.bot.http_session.get(url=SEARCH_API, params=params) as resp: + if resp.status != 200: + log.info(f"Unexpected response `{resp.status}` while searching wikipedia for `{search}`") + raise APIError("Wikipedia API", resp.status) + + raw_data = await resp.json() + + if not raw_data.get("query"): + if error := raw_data.get("errors"): + log.error(f"There was an error while communicating with the Wikipedia API: {error}") + raise APIError("Wikipedia API", resp.status, error) + + lines = [] + if raw_data["query"]["searchinfo"]["totalhits"]: + for article in raw_data["query"]["search"]: + line = WIKI_SEARCH_RESULT.format( + name=article["title"], + description=unescape( + re.sub( + WIKI_SNIPPET_REGEX, "", article["snippet"] + ) + ), + url=f"https://en.wikipedia.org/?curid={article['pageid']}" + ) + lines.append(line) + + return lines + + @commands.cooldown(1, 10, commands.BucketType.user) + @commands.command(name="wikipedia", aliases=("wiki",)) + async def wikipedia_search_command(self, ctx: commands.Context, *, search: str) -> None: + """Sends paginated top 10 results of Wikipedia search..""" + contents = await self.wiki_request(ctx.channel, search) + + if contents: + embed = Embed( + title="Wikipedia Search Results", + colour=Color.blurple() + ) + embed.set_thumbnail(url=WIKI_THUMBNAIL) + embed.timestamp = datetime.utcnow() + await LinePaginator.paginate( + contents, ctx, embed + ) + else: + await ctx.send( + "Sorry, we could not find a wikipedia article using that search term." + ) + + +def setup(bot: Bot) -> None: + """Load the WikipediaSearch cog.""" + bot.add_cog(WikipediaSearch(bot)) diff --git a/bot/exts/utilities/wolfram.py b/bot/exts/utilities/wolfram.py new file mode 100644 index 00000000..9a26e545 --- /dev/null +++ b/bot/exts/utilities/wolfram.py @@ -0,0 +1,293 @@ +import logging +from io import BytesIO +from typing import Callable, Optional +from urllib.parse import urlencode + +import arrow +import discord +from discord import Embed +from discord.ext import commands +from discord.ext.commands import BucketType, Cog, Context, check, group + +from bot.bot import Bot +from bot.constants import Colours, STAFF_ROLES, Wolfram +from bot.utils.pagination import ImagePaginator + +log = logging.getLogger(__name__) + +APPID = Wolfram.key +DEFAULT_OUTPUT_FORMAT = "JSON" +QUERY = "http://api.wolframalpha.com/v2/{request}" +WOLF_IMAGE = "https://www.symbols.com/gi.php?type=1&id=2886&i=1" + +MAX_PODS = 20 + +# Allows for 10 wolfram calls pr user pr day +usercd = commands.CooldownMapping.from_cooldown(Wolfram.user_limit_day, 60 * 60 * 24, BucketType.user) + +# Allows for max api requests / days in month per day for the entire guild (Temporary) +guildcd = commands.CooldownMapping.from_cooldown(Wolfram.guild_limit_day, 60 * 60 * 24, BucketType.guild) + + +async def send_embed( + ctx: Context, + message_txt: str, + colour: int = Colours.soft_red, + footer: str = None, + img_url: str = None, + f: discord.File = None +) -> None: + """Generate & send a response embed with Wolfram as the author.""" + embed = Embed(colour=colour) + embed.description = message_txt + embed.set_author( + name="Wolfram Alpha", + icon_url=WOLF_IMAGE, + url="https://www.wolframalpha.com/" + ) + if footer: + embed.set_footer(text=footer) + + if img_url: + embed.set_image(url=img_url) + + await ctx.send(embed=embed, file=f) + + +def custom_cooldown(*ignore: int) -> Callable: + """ + Implement per-user and per-guild cooldowns for requests to the Wolfram API. + + A list of roles may be provided to ignore the per-user cooldown. + """ + async def predicate(ctx: Context) -> bool: + if ctx.invoked_with == "help": + # if the invoked command is help we don't want to increase the ratelimits since it's not actually + # invoking the command/making a request, so instead just check if the user/guild are on cooldown. + guild_cooldown = not guildcd.get_bucket(ctx.message).get_tokens() == 0 # if guild is on cooldown + # check the message is in a guild, and check user bucket if user is not ignored + if ctx.guild and not any(r.id in ignore for r in ctx.author.roles): + return guild_cooldown and not usercd.get_bucket(ctx.message).get_tokens() == 0 + return guild_cooldown + + user_bucket = usercd.get_bucket(ctx.message) + + if all(role.id not in ignore for role in ctx.author.roles): + user_rate = user_bucket.update_rate_limit() + + if user_rate: + # Can't use api; cause: member limit + cooldown = arrow.utcnow().shift(seconds=int(user_rate)).humanize(only_distance=True) + message = ( + "You've used up your limit for Wolfram|Alpha requests.\n" + f"Cooldown: {cooldown}" + ) + await send_embed(ctx, message) + return False + + guild_bucket = guildcd.get_bucket(ctx.message) + guild_rate = guild_bucket.update_rate_limit() + + # Repr has a token attribute to read requests left + log.debug(guild_bucket) + + if guild_rate: + # Can't use api; cause: guild limit + message = ( + "The max limit of requests for the server has been reached for today.\n" + f"Cooldown: {int(guild_rate)}" + ) + await send_embed(ctx, message) + return False + + return True + + return check(predicate) + + +async def get_pod_pages(ctx: Context, bot: Bot, query: str) -> Optional[list[tuple[str, str]]]: + """Get the Wolfram API pod pages for the provided query.""" + async with ctx.typing(): + params = { + "input": query, + "appid": APPID, + "output": DEFAULT_OUTPUT_FORMAT, + "format": "image,plaintext", + "location": "the moon", + "latlong": "0.0,0.0", + "ip": "1.1.1.1" + } + request_url = QUERY.format(request="query") + + async with bot.http_session.get(url=request_url, params=params) as response: + json = await response.json(content_type="text/plain") + + result = json["queryresult"] + log_full_url = f"{request_url}?{urlencode(params)}" + if result["error"]: + # API key not set up correctly + if result["error"]["msg"] == "Invalid appid": + message = "Wolfram API key is invalid or missing." + log.warning( + "API key seems to be missing, or invalid when " + f"processing a wolfram request: {log_full_url}, Response: {json}" + ) + await send_embed(ctx, message) + return None + + message = "Something went wrong internally with your request, please notify staff!" + log.warning(f"Something went wrong getting a response from wolfram: {log_full_url}, Response: {json}") + await send_embed(ctx, message) + return None + + if not result["success"]: + message = f"I couldn't find anything for {query}." + await send_embed(ctx, message) + return None + + if not result["numpods"]: + message = "Could not find any results." + await send_embed(ctx, message) + return None + + pods = result["pods"] + pages = [] + for pod in pods[:MAX_PODS]: + subs = pod.get("subpods") + + for sub in subs: + title = sub.get("title") or sub.get("plaintext") or sub.get("id", "") + img = sub["img"]["src"] + pages.append((title, img)) + return pages + + +class Wolfram(Cog): + """Commands for interacting with the Wolfram|Alpha API.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @group(name="wolfram", aliases=("wolf", "wa"), invoke_without_command=True) + @custom_cooldown(*STAFF_ROLES) + async def wolfram_command(self, ctx: Context, *, query: str) -> None: + """Requests all answers on a single image, sends an image of all related pods.""" + params = { + "i": query, + "appid": APPID, + "location": "the moon", + "latlong": "0.0,0.0", + "ip": "1.1.1.1" + } + request_url = QUERY.format(request="simple") + + # Give feedback that the bot is working. + async with ctx.typing(): + async with self.bot.http_session.get(url=request_url, params=params) as response: + status = response.status + image_bytes = await response.read() + + f = discord.File(BytesIO(image_bytes), filename="image.png") + image_url = "attachment://image.png" + + if status == 501: + message = "Failed to get response." + footer = "" + color = Colours.soft_red + elif status == 400: + message = "No input found." + footer = "" + color = Colours.soft_red + elif status == 403: + message = "Wolfram API key is invalid or missing." + footer = "" + color = Colours.soft_red + else: + message = "" + footer = "View original for a bigger picture." + color = Colours.soft_orange + + # Sends a "blank" embed if no request is received, unsure how to fix + await send_embed(ctx, message, color, footer=footer, img_url=image_url, f=f) + + @wolfram_command.command(name="page", aliases=("pa", "p")) + @custom_cooldown(*STAFF_ROLES) + async def wolfram_page_command(self, ctx: Context, *, query: str) -> None: + """ + Requests a drawn image of given query. + + Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc. + """ + pages = await get_pod_pages(ctx, self.bot, query) + + if not pages: + return + + embed = Embed() + embed.set_author( + name="Wolfram Alpha", + icon_url=WOLF_IMAGE, + url="https://www.wolframalpha.com/" + ) + embed.colour = Colours.soft_orange + + await ImagePaginator.paginate(pages, ctx, embed) + + @wolfram_command.command(name="cut", aliases=("c",)) + @custom_cooldown(*STAFF_ROLES) + async def wolfram_cut_command(self, ctx: Context, *, query: str) -> None: + """ + Requests a drawn image of given query. + + Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc. + """ + pages = await get_pod_pages(ctx, self.bot, query) + + if not pages: + return + + if len(pages) >= 2: + page = pages[1] + else: + page = pages[0] + + await send_embed(ctx, page[0], colour=Colours.soft_orange, img_url=page[1]) + + @wolfram_command.command(name="short", aliases=("sh", "s")) + @custom_cooldown(*STAFF_ROLES) + async def wolfram_short_command(self, ctx: Context, *, query: str) -> None: + """Requests an answer to a simple question.""" + params = { + "i": query, + "appid": APPID, + "location": "the moon", + "latlong": "0.0,0.0", + "ip": "1.1.1.1" + } + request_url = QUERY.format(request="result") + + # Give feedback that the bot is working. + async with ctx.typing(): + async with self.bot.http_session.get(url=request_url, params=params) as response: + status = response.status + response_text = await response.text() + + if status == 501: + message = "Failed to get response." + color = Colours.soft_red + elif status == 400: + message = "No input found." + color = Colours.soft_red + elif response_text == "Error 1: Invalid appid.": + message = "Wolfram API key is invalid or missing." + color = Colours.soft_red + else: + message = response_text + color = Colours.soft_orange + + await send_embed(ctx, message, color) + + +def setup(bot: Bot) -> None: + """Load the Wolfram cog.""" + bot.add_cog(Wolfram(bot)) diff --git a/bot/resources/evergreen/py_topics.yaml b/bot/resources/evergreen/py_topics.yaml deleted file mode 100644 index a3fb2ccc..00000000 --- a/bot/resources/evergreen/py_topics.yaml +++ /dev/null @@ -1,139 +0,0 @@ -# Conversation starters for Python-related channels. - -# python-general -267624335836053506: - - What's your favorite PEP? - - What parts of your life has Python automated, if any? - - Which Python project are you the most proud of making? - - What made you want to learn Python? - - When did you start learning Python? - - What reasons are you learning Python for? - - Where's the strangest place you've seen Python? - - How has learning Python changed your life? - - Is there a package you wish existed but doesn't? What is it? - - What feature do you think should be added to Python? - - Has Python helped you in school? If so, how? - - What was the first thing you created with Python? - - What is your favorite Python package? - - What standard library module is really underrated? - - Have you published any packages on PyPi? If so, what are they? - - What are you currently working on in Python? - - What's your favorite script and how has it helped you in day to day activities? - - When you were first learning, what is something that stumped you? - - When you were first learning, what is a resource you wish you had? - - What is something you know now, that you wish you knew when starting out? - - What is something simple that you still error on today? - - What do you plan on eventually achieving with Python? - - Is Python your first programming language? If not, what is it? - - What's your favourite aspect of Python development? (Backend, frontend, game dev, machine learning, ai, etc.) - - In what ways has Python Discord helped you with Python? - - Are you currently using Python professionally, for education, or as a hobby? - - What is your process when you decide to start a project in Python? - - Have you ever been unable to finish a Python project? What is it and why? - - How often do you program in Python? - - How would you learn a new library if needed to do so? - - Have you ever worked with a microcontroller or anything physical with Python before? - - How good would you say you are at Python so far? Beginner, intermediate, or advanced? - - Have you ever tried making your own programming language? - - Has a recently discovered Python module changed your general use of Python? - -# algos-and-data-structs -650401909852864553: - - - -# async -630504881542791169: - - Are there any frameworks you wish were async? - - How have coroutines changed the way you write Python? - - What is your favorite async library? - -# c-extensions -728390945384431688: - - - -# databases -342318764227821568: - - Where do you get your best data? - - What is your preferred database and for what use? - -# data-science -366673247892275221: - - - -# discord.py -343944376055103488: - - What unique features does your bot contain, if any? - - What commands/features are you proud of making? - - What feature would you be the most interested in making? - - What feature would you like to see added to the library? What feature in the library do you think is redundant? - - Do you think there's a way in which Discord could handle bots better? - - What's one feature you wish more developers had in their bots? - -# editors-ides -813178633006350366: - - What's your current text editor/IDE, and what functionality do you like about it the most when programming in Python? - - What functionality is your text editor/IDE missing for programming Python? - -# esoteric-python -470884583684964352: - - What's a common part of programming we can make harder? - - What are the pros and cons of messing with __magic__()? - - What's your favorite Python hack? - -# game-development -660625198390837248: - - What is your favorite game mechanic? - - What is your favorite framework and why? - - What games do you know that were written in Python? - - What books or tutorials would you recommend for game-development beginners? - - What made you start developing games? - -# microcontrollers -545603026732318730: - - What is your favorite version of the Raspberry Pi? - -# networking -716325106619777044: - - If you could wish for a library involving networking, what would it be? - -# security -366674035876167691: - - If you could wish for a library involving net-sec, what would it be? - -# software-design -782713858615017503: - - - -# tools-and-devops -463035462760792066: - - What editor would you recommend to a beginner? Why? - - What editor would you recommend to be the most efficient? Why? - - How often do you use GitHub Actions and workflows to automate your repositories? - - What's your favorite app on GitHub? - -# unit-testing -463035728335732738: - - - -# unix -491523972836360192: - - What's your favorite Bash command? - - What's your most used Bash command? - - How often do you update your Unix machine? - - How often do you upgrade on production? - -# user-interfaces -338993628049571840: - - What's the most impressive Desktop Application you've made with Python so far? - - Have you ever made your own GUI? If so, how? - - Do you perfer Command Line Interfaces (CLI) or Graphic User Interfaces (GUI)? - - What's your favorite CLI (Command Line Interface) or TUI (Terminal Line Interface)? - - What's your best GUI project? - -# web-development -366673702533988363: - - How has Python helped you in web development? - - What tools do you use for web development? - - What is your favorite API library? - - What do you use for your frontend? - - What does your stack look like? diff --git a/bot/resources/evergreen/python_facts.txt b/bot/resources/evergreen/python_facts.txt deleted file mode 100644 index 0abd971b..00000000 --- a/bot/resources/evergreen/python_facts.txt +++ /dev/null @@ -1,3 +0,0 @@ -Python was named after Monty Python, a British Comedy Troupe, which Guido van Rossum likes. -If you type `import this` in the Python REPL, you'll get a poem about the philosophies about Python. (check it out by doing !zen in <#267659945086812160>) -If you type `import antigravity` in the Python REPL, you'll be directed to an [xkcd comic](https://xkcd.com/353/) about how easy Python is. diff --git a/bot/resources/evergreen/starter.yaml b/bot/resources/evergreen/starter.yaml deleted file mode 100644 index 6b0de0ef..00000000 --- a/bot/resources/evergreen/starter.yaml +++ /dev/null @@ -1,51 +0,0 @@ -# Conversation starters for channels that are not Python-related. - -- What is your favourite Easter candy or treat? -- What is your earliest memory of Easter? -- What is the title of the last book you read? -- "What is better: Milk, Dark or White chocolate?" -- What is your favourite holiday? -- If you could have any superpower, what would it be? -- If you could be anyone else for one day, who would it be? -- What Easter tradition do you enjoy most? -- What is the best gift you've been given? -- Name one famous person you would like to have at your easter dinner. -- What was the last movie you saw in a cinema? -- What is your favourite food? -- If you could travel anywhere in the world, where would you go? -- Tell us 5 things you do well. -- What is your favourite place that you have visited? -- What is your favourite color? -- If you had $100 bill in your Easter Basket, what would you do with it? -- What would you do if you know you could succeed at anything you chose to do? -- If you could take only three things from your house, what would they be? -- What's the best pastry? -- What's your favourite kind of soup? -- What is the most useless talent that you have? -- Would you rather fight 100 duck sized horses or one horse sized duck? -- What is your favourite color? -- What's your favourite type of weather? -- Tea or coffee? What about milk? -- Do you speak a language other than English? -- What is your favorite TV show? -- What is your favorite media genre? -- How many years have you spent coding? -- What book do you highly recommend everyone to read? -- What websites do you use daily to keep yourself up to date with the industry? -- What made you want to join this Discord server? -- How are you? -- What is the best advice you have ever gotten in regards to programming/software? -- What is the most satisfying thing you've done in your life? -- Who is your favorite music composer/producer/singer? -- What is your favorite song? -- What is your favorite video game? -- What are your hobbies other than programming? -- Who is your favorite Writer? -- What is your favorite movie? -- What is your favorite sport? -- What is your favorite fruit? -- What is your favorite juice? -- What is the best scenery you've ever seen? -- What artistic talents do you have? -- What is the tallest building you've entered? -- What is the oldest computer you've ever used? diff --git a/bot/resources/utilities/py_topics.yaml b/bot/resources/utilities/py_topics.yaml new file mode 100644 index 00000000..a3fb2ccc --- /dev/null +++ b/bot/resources/utilities/py_topics.yaml @@ -0,0 +1,139 @@ +# Conversation starters for Python-related channels. + +# python-general +267624335836053506: + - What's your favorite PEP? + - What parts of your life has Python automated, if any? + - Which Python project are you the most proud of making? + - What made you want to learn Python? + - When did you start learning Python? + - What reasons are you learning Python for? + - Where's the strangest place you've seen Python? + - How has learning Python changed your life? + - Is there a package you wish existed but doesn't? What is it? + - What feature do you think should be added to Python? + - Has Python helped you in school? If so, how? + - What was the first thing you created with Python? + - What is your favorite Python package? + - What standard library module is really underrated? + - Have you published any packages on PyPi? If so, what are they? + - What are you currently working on in Python? + - What's your favorite script and how has it helped you in day to day activities? + - When you were first learning, what is something that stumped you? + - When you were first learning, what is a resource you wish you had? + - What is something you know now, that you wish you knew when starting out? + - What is something simple that you still error on today? + - What do you plan on eventually achieving with Python? + - Is Python your first programming language? If not, what is it? + - What's your favourite aspect of Python development? (Backend, frontend, game dev, machine learning, ai, etc.) + - In what ways has Python Discord helped you with Python? + - Are you currently using Python professionally, for education, or as a hobby? + - What is your process when you decide to start a project in Python? + - Have you ever been unable to finish a Python project? What is it and why? + - How often do you program in Python? + - How would you learn a new library if needed to do so? + - Have you ever worked with a microcontroller or anything physical with Python before? + - How good would you say you are at Python so far? Beginner, intermediate, or advanced? + - Have you ever tried making your own programming language? + - Has a recently discovered Python module changed your general use of Python? + +# algos-and-data-structs +650401909852864553: + - + +# async +630504881542791169: + - Are there any frameworks you wish were async? + - How have coroutines changed the way you write Python? + - What is your favorite async library? + +# c-extensions +728390945384431688: + - + +# databases +342318764227821568: + - Where do you get your best data? + - What is your preferred database and for what use? + +# data-science +366673247892275221: + - + +# discord.py +343944376055103488: + - What unique features does your bot contain, if any? + - What commands/features are you proud of making? + - What feature would you be the most interested in making? + - What feature would you like to see added to the library? What feature in the library do you think is redundant? + - Do you think there's a way in which Discord could handle bots better? + - What's one feature you wish more developers had in their bots? + +# editors-ides +813178633006350366: + - What's your current text editor/IDE, and what functionality do you like about it the most when programming in Python? + - What functionality is your text editor/IDE missing for programming Python? + +# esoteric-python +470884583684964352: + - What's a common part of programming we can make harder? + - What are the pros and cons of messing with __magic__()? + - What's your favorite Python hack? + +# game-development +660625198390837248: + - What is your favorite game mechanic? + - What is your favorite framework and why? + - What games do you know that were written in Python? + - What books or tutorials would you recommend for game-development beginners? + - What made you start developing games? + +# microcontrollers +545603026732318730: + - What is your favorite version of the Raspberry Pi? + +# networking +716325106619777044: + - If you could wish for a library involving networking, what would it be? + +# security +366674035876167691: + - If you could wish for a library involving net-sec, what would it be? + +# software-design +782713858615017503: + - + +# tools-and-devops +463035462760792066: + - What editor would you recommend to a beginner? Why? + - What editor would you recommend to be the most efficient? Why? + - How often do you use GitHub Actions and workflows to automate your repositories? + - What's your favorite app on GitHub? + +# unit-testing +463035728335732738: + - + +# unix +491523972836360192: + - What's your favorite Bash command? + - What's your most used Bash command? + - How often do you update your Unix machine? + - How often do you upgrade on production? + +# user-interfaces +338993628049571840: + - What's the most impressive Desktop Application you've made with Python so far? + - Have you ever made your own GUI? If so, how? + - Do you perfer Command Line Interfaces (CLI) or Graphic User Interfaces (GUI)? + - What's your favorite CLI (Command Line Interface) or TUI (Terminal Line Interface)? + - What's your best GUI project? + +# web-development +366673702533988363: + - How has Python helped you in web development? + - What tools do you use for web development? + - What is your favorite API library? + - What do you use for your frontend? + - What does your stack look like? diff --git a/bot/resources/utilities/python_facts.txt b/bot/resources/utilities/python_facts.txt new file mode 100644 index 00000000..0abd971b --- /dev/null +++ b/bot/resources/utilities/python_facts.txt @@ -0,0 +1,3 @@ +Python was named after Monty Python, a British Comedy Troupe, which Guido van Rossum likes. +If you type `import this` in the Python REPL, you'll get a poem about the philosophies about Python. (check it out by doing !zen in <#267659945086812160>) +If you type `import antigravity` in the Python REPL, you'll be directed to an [xkcd comic](https://xkcd.com/353/) about how easy Python is. diff --git a/bot/resources/utilities/starter.yaml b/bot/resources/utilities/starter.yaml new file mode 100644 index 00000000..6b0de0ef --- /dev/null +++ b/bot/resources/utilities/starter.yaml @@ -0,0 +1,51 @@ +# Conversation starters for channels that are not Python-related. + +- What is your favourite Easter candy or treat? +- What is your earliest memory of Easter? +- What is the title of the last book you read? +- "What is better: Milk, Dark or White chocolate?" +- What is your favourite holiday? +- If you could have any superpower, what would it be? +- If you could be anyone else for one day, who would it be? +- What Easter tradition do you enjoy most? +- What is the best gift you've been given? +- Name one famous person you would like to have at your easter dinner. +- What was the last movie you saw in a cinema? +- What is your favourite food? +- If you could travel anywhere in the world, where would you go? +- Tell us 5 things you do well. +- What is your favourite place that you have visited? +- What is your favourite color? +- If you had $100 bill in your Easter Basket, what would you do with it? +- What would you do if you know you could succeed at anything you chose to do? +- If you could take only three things from your house, what would they be? +- What's the best pastry? +- What's your favourite kind of soup? +- What is the most useless talent that you have? +- Would you rather fight 100 duck sized horses or one horse sized duck? +- What is your favourite color? +- What's your favourite type of weather? +- Tea or coffee? What about milk? +- Do you speak a language other than English? +- What is your favorite TV show? +- What is your favorite media genre? +- How many years have you spent coding? +- What book do you highly recommend everyone to read? +- What websites do you use daily to keep yourself up to date with the industry? +- What made you want to join this Discord server? +- How are you? +- What is the best advice you have ever gotten in regards to programming/software? +- What is the most satisfying thing you've done in your life? +- Who is your favorite music composer/producer/singer? +- What is your favorite song? +- What is your favorite video game? +- What are your hobbies other than programming? +- Who is your favorite Writer? +- What is your favorite movie? +- What is your favorite sport? +- What is your favorite fruit? +- What is your favorite juice? +- What is the best scenery you've ever seen? +- What artistic talents do you have? +- What is the tallest building you've entered? +- What is the oldest computer you've ever used? -- cgit v1.2.3 From 9c84accda87a83381e64bf8182777be9ef128b1e Mon Sep 17 00:00:00 2001 From: Janine vN Date: Sun, 5 Sep 2021 00:19:46 -0400 Subject: Move snakes commands into fun folder --- bot/exts/evergreen/snakes/__init__.py | 11 - bot/exts/evergreen/snakes/_converter.py | 82 - bot/exts/evergreen/snakes/_snakes_cog.py | 1151 ----------- bot/exts/evergreen/snakes/_utils.py | 721 ------- bot/exts/fun/snakes/__init__.py | 11 + bot/exts/fun/snakes/_converter.py | 82 + bot/exts/fun/snakes/_snakes_cog.py | 1151 +++++++++++ bot/exts/fun/snakes/_utils.py | 721 +++++++ .../fun/snakes/snake_cards/backs/card_back1.jpg | Bin 0 -> 165788 bytes .../fun/snakes/snake_cards/backs/card_back2.jpg | Bin 0 -> 140868 bytes .../fun/snakes/snake_cards/card_bottom.png | Bin 0 -> 18165 bytes .../fun/snakes/snake_cards/card_frame.png | Bin 0 -> 1460 bytes bot/resources/fun/snakes/snake_cards/card_top.png | Bin 0 -> 12581 bytes .../fun/snakes/snake_cards/expressway.ttf | Bin 0 -> 156244 bytes bot/resources/fun/snakes/snake_facts.json | 233 +++ bot/resources/fun/snakes/snake_idioms.json | 275 +++ bot/resources/fun/snakes/snake_names.json | 2170 ++++++++++++++++++++ bot/resources/fun/snakes/snake_quiz.json | 200 ++ .../fun/snakes/snakes_and_ladders/banner.jpg | Bin 0 -> 17928 bytes .../fun/snakes/snakes_and_ladders/board.jpg | Bin 0 -> 80264 bytes bot/resources/fun/snakes/special_snakes.json | 16 + .../snakes/snake_cards/backs/card_back1.jpg | Bin 165788 -> 0 bytes .../snakes/snake_cards/backs/card_back2.jpg | Bin 140868 -> 0 bytes bot/resources/snakes/snake_cards/card_bottom.png | Bin 18165 -> 0 bytes bot/resources/snakes/snake_cards/card_frame.png | Bin 1460 -> 0 bytes bot/resources/snakes/snake_cards/card_top.png | Bin 12581 -> 0 bytes bot/resources/snakes/snake_cards/expressway.ttf | Bin 156244 -> 0 bytes bot/resources/snakes/snake_facts.json | 233 --- bot/resources/snakes/snake_idioms.json | 275 --- bot/resources/snakes/snake_names.json | 2170 -------------------- bot/resources/snakes/snake_quiz.json | 200 -- bot/resources/snakes/snakes_and_ladders/banner.jpg | Bin 17928 -> 0 bytes bot/resources/snakes/snakes_and_ladders/board.jpg | Bin 80264 -> 0 bytes bot/resources/snakes/special_snakes.json | 16 - 34 files changed, 4859 insertions(+), 4859 deletions(-) delete mode 100644 bot/exts/evergreen/snakes/__init__.py delete mode 100644 bot/exts/evergreen/snakes/_converter.py delete mode 100644 bot/exts/evergreen/snakes/_snakes_cog.py delete mode 100644 bot/exts/evergreen/snakes/_utils.py create mode 100644 bot/exts/fun/snakes/__init__.py create mode 100644 bot/exts/fun/snakes/_converter.py create mode 100644 bot/exts/fun/snakes/_snakes_cog.py create mode 100644 bot/exts/fun/snakes/_utils.py create mode 100644 bot/resources/fun/snakes/snake_cards/backs/card_back1.jpg create mode 100644 bot/resources/fun/snakes/snake_cards/backs/card_back2.jpg create mode 100644 bot/resources/fun/snakes/snake_cards/card_bottom.png create mode 100644 bot/resources/fun/snakes/snake_cards/card_frame.png create mode 100644 bot/resources/fun/snakes/snake_cards/card_top.png create mode 100644 bot/resources/fun/snakes/snake_cards/expressway.ttf create mode 100644 bot/resources/fun/snakes/snake_facts.json create mode 100644 bot/resources/fun/snakes/snake_idioms.json create mode 100644 bot/resources/fun/snakes/snake_names.json create mode 100644 bot/resources/fun/snakes/snake_quiz.json create mode 100644 bot/resources/fun/snakes/snakes_and_ladders/banner.jpg create mode 100644 bot/resources/fun/snakes/snakes_and_ladders/board.jpg create mode 100644 bot/resources/fun/snakes/special_snakes.json delete mode 100644 bot/resources/snakes/snake_cards/backs/card_back1.jpg delete mode 100644 bot/resources/snakes/snake_cards/backs/card_back2.jpg delete mode 100644 bot/resources/snakes/snake_cards/card_bottom.png delete mode 100644 bot/resources/snakes/snake_cards/card_frame.png delete mode 100644 bot/resources/snakes/snake_cards/card_top.png delete mode 100644 bot/resources/snakes/snake_cards/expressway.ttf delete mode 100644 bot/resources/snakes/snake_facts.json delete mode 100644 bot/resources/snakes/snake_idioms.json delete mode 100644 bot/resources/snakes/snake_names.json delete mode 100644 bot/resources/snakes/snake_quiz.json delete mode 100644 bot/resources/snakes/snakes_and_ladders/banner.jpg delete mode 100644 bot/resources/snakes/snakes_and_ladders/board.jpg delete mode 100644 bot/resources/snakes/special_snakes.json (limited to 'bot') diff --git a/bot/exts/evergreen/snakes/__init__.py b/bot/exts/evergreen/snakes/__init__.py deleted file mode 100644 index 7740429b..00000000 --- a/bot/exts/evergreen/snakes/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -import logging - -from bot.bot import Bot -from bot.exts.evergreen.snakes._snakes_cog import Snakes - -log = logging.getLogger(__name__) - - -def setup(bot: Bot) -> None: - """Load the Snakes Cog.""" - bot.add_cog(Snakes(bot)) diff --git a/bot/exts/evergreen/snakes/_converter.py b/bot/exts/evergreen/snakes/_converter.py deleted file mode 100644 index 765b983d..00000000 --- a/bot/exts/evergreen/snakes/_converter.py +++ /dev/null @@ -1,82 +0,0 @@ -import json -import logging -import random -from collections.abc import Iterable - -import discord -from discord.ext.commands import Context, Converter -from rapidfuzz 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.display_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: - cls.snakes = json.loads((SNAKE_RESOURCES / "snake_names.json").read_text("utf8")) - # Get the special cases - if cls.special_cases is None: - special_cases = json.loads((SNAKE_RESOURCES / "special_snakes.json").read_text("utf8")) - 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 deleted file mode 100644 index 04804222..00000000 --- a/bot/exts/evergreen/snakes/_snakes_cog.py +++ /dev/null @@ -1,1151 +0,0 @@ -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, Optional - -import async_timeout -from PIL import Image, ImageDraw, ImageFont -from discord import Colour, Embed, File, Member, Message, Reaction -from discord.errors import HTTPException -from discord.ext.commands import Cog, CommandError, Context, bot_has_permissions, group - -from bot.bot import Bot -from bot.constants import ERROR_REPLIES, Tokens -from bot.exts.evergreen.snakes import _utils as utils -from bot.exts.evergreen.snakes._converter import Snake -from bot.utils.decorators import locked -from bot.utils.extensions import invoke_help_command - -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") - self.num_movie_pages = None - - # 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, url: str, params: Optional[dict] = None) -> dict: - """Asynchronous web request helper method.""" - if params is None: - params = {} - - async with async_timeout.timeout(10): - async with self.bot.http_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 = {} - - params = { - "format": "json", - "action": "query", - "list": "search", - "srsearch": name, - "utf8": "", - "srlimit": "1", - } - - json = await self._fetch(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(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: dict[str, str]) -> 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.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 invoke_help_command(ctx) - - @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.display_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.display_avatar.url) - antidote_embed.set_image(url="https://i.makeagif.com/media/7-12-2015/Cj1pts.gif") - antidote_embed.add_field(name="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.display_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"): - await ctx.send("Could not fetch data from Wikipedia.") - return - - 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" - - _iter = ( - url - for url in data["image_list"] - if url.endswith(self.valid_image_extensions) - ) - image = next(_iter, 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) - - _iter = ( - url - for url in data["image_list"] - if url.endswith(self.valid_image_extensions) - ) - image = next(_iter, 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.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.author.name, ctx.author.discriminator) - ) - - await ctx.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 TMDB. - - Written by Samuel. - Modified by gdude. - Modified by Will Da Silva. - """ - # Initially 8 pages are fetched. The actual number of pages is set after the first request. - page = random.randint(1, self.num_movie_pages or 8) - - async with ctx.typing(): - response = await self.bot.http_session.get( - "https://api.themoviedb.org/3/search/movie", - params={ - "query": "snake", - "page": page, - "language": "en-US", - "api_key": Tokens.tmdb, - } - ) - data = await response.json() - if self.num_movie_pages is None: - self.num_movie_pages = data["total_pages"] - movie = random.choice(data["results"])["id"] - - response = await self.bot.http_session.get( - f"https://api.themoviedb.org/3/movie/{movie}", - params={ - "language": "en-US", - "api_key": Tokens.tmdb, - } - ) - data = await response.json() - - embed = Embed(title=data["title"], color=SNAKE_COLOR) - - if data["poster_path"] is not None: - embed.set_image(url=f"https://images.tmdb.org/t/p/original{data['poster_path']}") - - if data["overview"]: - embed.add_field(name="Overview", value=data["overview"]) - - if data["release_date"]: - embed.add_field(name="Release Date", value=data["release_date"]) - - if data["genres"]: - embed.add_field(name="Genres", value=", ".join([x["name"] for x in data["genres"]])) - - if data["vote_count"]: - embed.add_field(name="Rating", value=f"{data['vote_average']}/10 ({data['vote_count']} votes)", inline=True) - - if data["budget"] and data["revenue"]: - embed.add_field(name="Budget", value=data["budget"], inline=True) - embed.add_field(name="Revenue", value=data["revenue"], inline=True) - - embed.set_footer(text="This product uses the TMDb API but is not endorsed or certified by TMDb.") - embed.set_thumbnail(url="https://i.imgur.com/LtFtC8H.png") - - try: - await ctx.send(embed=embed) - except HTTPException as err: - await ctx.send("An error occurred while fetching a snake-related movie!") - raise err from None - - @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.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 - ) - - await ctx.send(embed=embed) - return - - @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://pythondiscord.com/pages/code-jams/code-jam-1-snakes-bot/). \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> " - f"walked away as grand champions. Make sure you check out `{ctx.prefix}snakes sal`," - f"`{ctx.prefix}snakes draw` and `{ctx.prefix}snakes hatch` " - "to see what they came up with." - ) - ) - - embed.add_field( - name="Contributors", - value=( - ", ".join(contributors) - ) - ) - - await ctx.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.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.author - - if not message: - - # Get a random message from the users history - messages = [] - async for message in ctx.history(limit=500).filter( - lambda msg: msg.author == ctx.author # Message was sent by author. - ): - messages.append(message.content) - - message = self._get_random_long_message(messages) - - # Build and send the embed - embed.set_author( - name=f"{user.name}#{user.discriminator}", - icon_url=user.display_avatar.url, - ) - embed.description = f"*{self._snakify(message)}*" - - await ctx.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 = "https://www.googleapis.com/youtube/v3/search" - response = await self.bot.http_session.get( - url, - params={ - "part": "snippet", - "q": urllib.parse.quote_plus(query), - "type": "video", - "key": Tokens.youtube - } - ) - response = await response.json() - data = response.get("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.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.send( - embed=embed - ) - # endregion - - # region: Error handlers - @card_command.error - async def command_error(self, ctx: Context, error: CommandError) -> None: - """Local error handler for the Snake Cog.""" - original_error = getattr(error, "original", None) - if isinstance(original_error, OSError): - error.handled = True - embed = Embed() - embed.colour = Colour.red() - log.error(f"snake_card encountered an OSError: {error} ({original_error})") - embed.description = "Could not generate the snake card! Please try again." - embed.title = random.choice(ERROR_REPLIES) - await ctx.send(embed=embed) diff --git a/bot/exts/evergreen/snakes/_utils.py b/bot/exts/evergreen/snakes/_utils.py deleted file mode 100644 index b5f13c53..00000000 --- a/bot/exts/evergreen/snakes/_utils.py +++ /dev/null @@ -1,721 +0,0 @@ -import asyncio -import io -import json -import logging -import math -import random -from itertools import product -from pathlib import Path - -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", - "Baby Anaconda": "https://i.imgur.com/EpdrnNr.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 = 0x15c7ea -DEFAULT_BACKGROUND_COLOR = 0 -DEFAULT_IMAGE_DIMENSIONS = (200, 200) -DEFAULT_SNAKE_LENGTH = 22 -DEFAULT_SNAKE_WIDTH = 8 -DEFAULT_SEGMENT_LENGTH_RANGE = (7, 10) -DEFAULT_IMAGE_MARGINS = (50, 50) -DEFAULT_TEXT = "snek\nit\nup" -DEFAULT_TEXT_POSITION = ( - 10, - 10 -) -DEFAULT_TEXT_COLOR = 0xf2ea15 -X = 0 -Y = 1 -ANGLE_RANGE = math.pi * 2 - - -def get_resource(file: str) -> list[dict]: - """Load Snake resources JSON.""" - return json.loads((SNAKE_RESOURCES / f"{file}.json").read_text("utf-8")) - - -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( - f"Expected {self.dimension} values, got {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, int] = DEFAULT_IMAGE_DIMENSIONS, - image_margins: tuple[int, 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, int] = DEFAULT_SEGMENT_LENGTH_RANGE, snake_width: int = DEFAULT_SNAKE_WIDTH, - text: str = DEFAULT_TEXT, text_position: tuple[float, float] = DEFAULT_TEXT_POSITION, - text_color: int = DEFAULT_TEXT_COLOR -) -> Image.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: list[tuple[float, float]] = [(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: list[float] = [start_x, start_y] - max_dimensions: list[float] = [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.display_avatar.replace(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(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/fun/snakes/__init__.py b/bot/exts/fun/snakes/__init__.py new file mode 100644 index 00000000..ba8333fd --- /dev/null +++ b/bot/exts/fun/snakes/__init__.py @@ -0,0 +1,11 @@ +import logging + +from bot.bot import Bot +from bot.exts.fun.snakes._snakes_cog import Snakes + +log = logging.getLogger(__name__) + + +def setup(bot: Bot) -> None: + """Load the Snakes Cog.""" + bot.add_cog(Snakes(bot)) diff --git a/bot/exts/fun/snakes/_converter.py b/bot/exts/fun/snakes/_converter.py new file mode 100644 index 00000000..c24ba8c6 --- /dev/null +++ b/bot/exts/fun/snakes/_converter.py @@ -0,0 +1,82 @@ +import json +import logging +import random +from collections.abc import Iterable + +import discord +from discord.ext.commands import Context, Converter +from rapidfuzz import fuzz + +from bot.exts.fun.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.display_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: + cls.snakes = json.loads((SNAKE_RESOURCES / "snake_names.json").read_text("utf8")) + # Get the special cases + if cls.special_cases is None: + special_cases = json.loads((SNAKE_RESOURCES / "special_snakes.json").read_text("utf8")) + 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/fun/snakes/_snakes_cog.py b/bot/exts/fun/snakes/_snakes_cog.py new file mode 100644 index 00000000..59e57199 --- /dev/null +++ b/bot/exts/fun/snakes/_snakes_cog.py @@ -0,0 +1,1151 @@ +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, Optional + +import async_timeout +from PIL import Image, ImageDraw, ImageFont +from discord import Colour, Embed, File, Member, Message, Reaction +from discord.errors import HTTPException +from discord.ext.commands import Cog, CommandError, Context, bot_has_permissions, group + +from bot.bot import Bot +from bot.constants import ERROR_REPLIES, Tokens +from bot.exts.fun.snakes import _utils as utils +from bot.exts.fun.snakes._converter import Snake +from bot.utils.decorators import locked +from bot.utils.extensions import invoke_help_command + +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/fun/snakes/snake_cards/card_top.png"), + "frame": Image.open("bot/resources/fun/snakes/snake_cards/card_frame.png"), + "bottom": Image.open("bot/resources/fun/snakes/snake_cards/card_bottom.png"), + "backs": [ + Image.open(f"bot/resources/fun/snakes/snake_cards/backs/{file}") + for file in os.listdir("bot/resources/fun/snakes/snake_cards/backs") + ], + "font": ImageFont.truetype("bot/resources/fun/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") + self.num_movie_pages = None + + # 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, url: str, params: Optional[dict] = None) -> dict: + """Asynchronous web request helper method.""" + if params is None: + params = {} + + async with async_timeout.timeout(10): + async with self.bot.http_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 = {} + + params = { + "format": "json", + "action": "query", + "list": "search", + "srsearch": name, + "utf8": "", + "srlimit": "1", + } + + json = await self._fetch(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(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: dict[str, str]) -> 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.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 invoke_help_command(ctx) + + @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.display_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.display_avatar.url) + antidote_embed.set_image(url="https://i.makeagif.com/media/7-12-2015/Cj1pts.gif") + antidote_embed.add_field(name="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.display_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"): + await ctx.send("Could not fetch data from Wikipedia.") + return + + 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" + + _iter = ( + url + for url in data["image_list"] + if url.endswith(self.valid_image_extensions) + ) + image = next(_iter, 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) + + _iter = ( + url + for url in data["image_list"] + if url.endswith(self.valid_image_extensions) + ) + image = next(_iter, 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.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.author.name, ctx.author.discriminator) + ) + + await ctx.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 TMDB. + + Written by Samuel. + Modified by gdude. + Modified by Will Da Silva. + """ + # Initially 8 pages are fetched. The actual number of pages is set after the first request. + page = random.randint(1, self.num_movie_pages or 8) + + async with ctx.typing(): + response = await self.bot.http_session.get( + "https://api.themoviedb.org/3/search/movie", + params={ + "query": "snake", + "page": page, + "language": "en-US", + "api_key": Tokens.tmdb, + } + ) + data = await response.json() + if self.num_movie_pages is None: + self.num_movie_pages = data["total_pages"] + movie = random.choice(data["results"])["id"] + + response = await self.bot.http_session.get( + f"https://api.themoviedb.org/3/movie/{movie}", + params={ + "language": "en-US", + "api_key": Tokens.tmdb, + } + ) + data = await response.json() + + embed = Embed(title=data["title"], color=SNAKE_COLOR) + + if data["poster_path"] is not None: + embed.set_image(url=f"https://images.tmdb.org/t/p/original{data['poster_path']}") + + if data["overview"]: + embed.add_field(name="Overview", value=data["overview"]) + + if data["release_date"]: + embed.add_field(name="Release Date", value=data["release_date"]) + + if data["genres"]: + embed.add_field(name="Genres", value=", ".join([x["name"] for x in data["genres"]])) + + if data["vote_count"]: + embed.add_field(name="Rating", value=f"{data['vote_average']}/10 ({data['vote_count']} votes)", inline=True) + + if data["budget"] and data["revenue"]: + embed.add_field(name="Budget", value=data["budget"], inline=True) + embed.add_field(name="Revenue", value=data["revenue"], inline=True) + + embed.set_footer(text="This product uses the TMDb API but is not endorsed or certified by TMDb.") + embed.set_thumbnail(url="https://i.imgur.com/LtFtC8H.png") + + try: + await ctx.send(embed=embed) + except HTTPException as err: + await ctx.send("An error occurred while fetching a snake-related movie!") + raise err from None + + @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.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 + ) + + await ctx.send(embed=embed) + return + + @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://pythondiscord.com/pages/code-jams/code-jam-1-snakes-bot/). \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> " + f"walked away as grand champions. Make sure you check out `{ctx.prefix}snakes sal`," + f"`{ctx.prefix}snakes draw` and `{ctx.prefix}snakes hatch` " + "to see what they came up with." + ) + ) + + embed.add_field( + name="Contributors", + value=( + ", ".join(contributors) + ) + ) + + await ctx.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.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.author + + if not message: + + # Get a random message from the users history + messages = [] + async for message in ctx.history(limit=500).filter( + lambda msg: msg.author == ctx.author # Message was sent by author. + ): + messages.append(message.content) + + message = self._get_random_long_message(messages) + + # Build and send the embed + embed.set_author( + name=f"{user.name}#{user.discriminator}", + icon_url=user.display_avatar.url, + ) + embed.description = f"*{self._snakify(message)}*" + + await ctx.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 = "https://www.googleapis.com/youtube/v3/search" + response = await self.bot.http_session.get( + url, + params={ + "part": "snippet", + "q": urllib.parse.quote_plus(query), + "type": "video", + "key": Tokens.youtube + } + ) + response = await response.json() + data = response.get("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.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.send( + embed=embed + ) + # endregion + + # region: Error handlers + @card_command.error + async def command_error(self, ctx: Context, error: CommandError) -> None: + """Local error handler for the Snake Cog.""" + original_error = getattr(error, "original", None) + if isinstance(original_error, OSError): + error.handled = True + embed = Embed() + embed.colour = Colour.red() + log.error(f"snake_card encountered an OSError: {error} ({original_error})") + embed.description = "Could not generate the snake card! Please try again." + embed.title = random.choice(ERROR_REPLIES) + await ctx.send(embed=embed) diff --git a/bot/exts/fun/snakes/_utils.py b/bot/exts/fun/snakes/_utils.py new file mode 100644 index 00000000..de51339d --- /dev/null +++ b/bot/exts/fun/snakes/_utils.py @@ -0,0 +1,721 @@ +import asyncio +import io +import json +import logging +import math +import random +from itertools import product +from pathlib import Path + +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/fun/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", + "Baby Anaconda": "https://i.imgur.com/EpdrnNr.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 = 0x15c7ea +DEFAULT_BACKGROUND_COLOR = 0 +DEFAULT_IMAGE_DIMENSIONS = (200, 200) +DEFAULT_SNAKE_LENGTH = 22 +DEFAULT_SNAKE_WIDTH = 8 +DEFAULT_SEGMENT_LENGTH_RANGE = (7, 10) +DEFAULT_IMAGE_MARGINS = (50, 50) +DEFAULT_TEXT = "snek\nit\nup" +DEFAULT_TEXT_POSITION = ( + 10, + 10 +) +DEFAULT_TEXT_COLOR = 0xf2ea15 +X = 0 +Y = 1 +ANGLE_RANGE = math.pi * 2 + + +def get_resource(file: str) -> list[dict]: + """Load Snake resources JSON.""" + return json.loads((SNAKE_RESOURCES / f"{file}.json").read_text("utf-8")) + + +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( + f"Expected {self.dimension} values, got {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, int] = DEFAULT_IMAGE_DIMENSIONS, + image_margins: tuple[int, 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, int] = DEFAULT_SEGMENT_LENGTH_RANGE, snake_width: int = DEFAULT_SNAKE_WIDTH, + text: str = DEFAULT_TEXT, text_position: tuple[float, float] = DEFAULT_TEXT_POSITION, + text_color: int = DEFAULT_TEXT_COLOR +) -> Image.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: list[tuple[float, float]] = [(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: list[float] = [start_x, start_y] + max_dimensions: list[float] = [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.display_avatar.replace(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(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/resources/fun/snakes/snake_cards/backs/card_back1.jpg b/bot/resources/fun/snakes/snake_cards/backs/card_back1.jpg new file mode 100644 index 00000000..22959fa7 Binary files /dev/null and b/bot/resources/fun/snakes/snake_cards/backs/card_back1.jpg differ diff --git a/bot/resources/fun/snakes/snake_cards/backs/card_back2.jpg b/bot/resources/fun/snakes/snake_cards/backs/card_back2.jpg new file mode 100644 index 00000000..d56edc32 Binary files /dev/null and b/bot/resources/fun/snakes/snake_cards/backs/card_back2.jpg differ diff --git a/bot/resources/fun/snakes/snake_cards/card_bottom.png b/bot/resources/fun/snakes/snake_cards/card_bottom.png new file mode 100644 index 00000000..8b2b91c5 Binary files /dev/null and b/bot/resources/fun/snakes/snake_cards/card_bottom.png differ diff --git a/bot/resources/fun/snakes/snake_cards/card_frame.png b/bot/resources/fun/snakes/snake_cards/card_frame.png new file mode 100644 index 00000000..149a0a5f Binary files /dev/null and b/bot/resources/fun/snakes/snake_cards/card_frame.png differ diff --git a/bot/resources/fun/snakes/snake_cards/card_top.png b/bot/resources/fun/snakes/snake_cards/card_top.png new file mode 100644 index 00000000..e329c873 Binary files /dev/null and b/bot/resources/fun/snakes/snake_cards/card_top.png differ diff --git a/bot/resources/fun/snakes/snake_cards/expressway.ttf b/bot/resources/fun/snakes/snake_cards/expressway.ttf new file mode 100644 index 00000000..39e15794 Binary files /dev/null and b/bot/resources/fun/snakes/snake_cards/expressway.ttf differ diff --git a/bot/resources/fun/snakes/snake_facts.json b/bot/resources/fun/snakes/snake_facts.json new file mode 100644 index 00000000..ca9ba769 --- /dev/null +++ b/bot/resources/fun/snakes/snake_facts.json @@ -0,0 +1,233 @@ +[ + { + "fact": "The decapitated head of a dead snake can still bite, even hours after death. These types of bites usually contain huge amounts of venom." + }, + { + "fact": "What is considered the most 'dangerous' snake depends on both a specific country’s health care and the availability of antivenom following a bite. Based on these criteria, the most dangerous snake in the world is the saw-scaled viper, which bites and kills more people each year than any other snake." + }, + { + "fact": "Snakes live everywhere on Earth except Ireland, Iceland, New Zealand, and the North and South Poles." + }, + { + "fact": "Of the approximately 725 species of venomous snakes worldwide, 250 can kill a human with one bite." + }, + { + "fact": "Snakes evolved from a four-legged reptilian ancestor—most likely a small, burrowing, land-bound lizard—about 100 million years ago. Some snakes, such as pythons and boas, still have traces of back legs." + }, + { + "fact": "The fear of snakes (ophiophobia or herpetophobia) is one of the most common phobias worldwide. Approximately 1/3 of all adult humans areophidiophobic , which suggests that humans have an innate, evolutionary fear of snakes." + }, + { + "fact": "The top 5 most venomous snakes in the world are the inland taipan, the eastern brown snake, the coastal taipan, the tiger snake, and the black tiger snake." + }, + { + "fact": "The warmer a snake’s body, the more quickly it can digest its prey. Typically, it takes 3–5 days for a snake to digest its meal. For very large snakes, such as the anaconda, digestion can take weeks." + }, + { + "fact": "Some animals, such as the Mongoose, are immune to snake venom." + }, + { + "fact": "To avoid predators, some snakes can poop whenever they want. They make themselves so dirty and smelly that predators will run away." + }, + { + "fact": "The heaviest snake in the world is the anaconda. It weighs over 595 pounds (270 kg) and can grow to over 30 feet (9m) long. It has been known to eat caimans, capybaras, and jaguars." + }, + { + "fact": "The Brahminy Blind Snake, or flowerpot snake, is the only snake species made up of solely females and, as such, does not need a mate to reproduce. It is also the most widespread terrestrial snake in the world." + }, + { + "fact": "If a person suddenly turned into a snake, they would be about 4 times longer than they are now and only a few inches thick. While humans have 24 ribs, some snakes can have more than 400." + }, + { + "fact": "The most advanced snake species in the world is believed to be the black mamba. It has the most highly evolved venom delivery system of any snake on Earth. It can strike up to 12 times in a row, though just one bite is enough to kill a grown man.o" + }, + { + "fact": "The inland taipan is the world’s most toxic snake, meaning it has both the most toxic venom and it injects the most venom when it bites. Its venom sacs hold enough poison to kill up to 80 people." + }, + { + "fact": "The death adder has the fastest strike of any snake in the world. It can attack, inject venom, and go back to striking position in under 0.15 seconds." + }, + { + "fact": "While snakes do not have external ears or eardrums, their skin, muscles, and bones carry sound vibrations to their inner ears." + }, + { + "fact": "Some snakes have been known to explode after eating a large meal. For example, a 13-foot python blew up after it tried to eat a 6-foot alligator. The python was found with the alligator’s tail protruding from its midsection. Its head was missing." + }, + { + "fact": "The word 'snake' is from the Proto-Indo-European root *sneg -, meaning 'to crawl, creeping thing.' The word 'serpent' is from the Proto-Indo-European root *serp -, meaning 'to crawl, creep.'" + }, + { + "fact": "Rattlesnake rattles are made of rings of keratin, which is the same material as human hair and fingernails. A rattler will add a new ring each time it sheds its skin." + }, + { + "fact": "Some snakes have over 200 teeth. The teeth aren’t used for chewing but they point backward to prevent prey from escaping the snake’s throat." + }, + { + "fact": "There are about 500 genera and 3,000 different species of snakes. All of them are predators." + }, + { + "fact": "Naturalist Paul Rosolie attempted to be the first person to survive being swallowed by an anaconda in 2014. Though he was wearing a specially designed carbon fiber suit equipped with a breathing system, cameras, and a communication system, he ultimately called off his stunt when he felt like the anaconda was breaking his arm as it tightened its grip around his body." + }, + { + "fact": "There are five recognized species of flying snakes. Growing up to 4 feet, some types can glide up to 330 feet through the air." + }, + { + "fact": "Scales cover every inch of a snake’s body, even its eyes. Scales are thick, tough pieces of skin made from keratin, which is the same material human nails and hair are made from." + }, + { + "fact": "The most common snake in North America is the garter (gardener) snake. This snake is also Massachusetts’s state reptile. While previously thought to be nonvenomous, garter snakes do, in fact, produce a mild neurotoxic venom that is harmless to humans." + }, + { + "fact": "Snakes do not lap up water like mammals do. Instead, they dunk their snouts underwater and use their throats to pump water into their stomachs." + }, + { + "fact": "A snake’s fangs usually last about 6–10 weeks. When a fang wears out, a new one grows in its place." + }, + { + "fact": "Because the end of a snake’s tongue is forked, the two tips taste different amounts of chemicals. Essentially, a snake 'smells in stereo' and can even tell which direction a smell is coming from. It identifies scents on its tongue using pits in the roof of its mouth called the Jacobson’s organ." + }, + { + "fact": "The amount of food a snake eats determines how many offspring it will have. The Arafura file snake eats the least and lays just one egg every decade." + }, + { + "fact": "While smaller snakes, such a tree- or- ground-dwelling snakes, use their tongues to follow the scent trails of prey (such as spiders, birds, and other snakes). Larger snakes, such as boas, have heat-sensing organs called labial (lip) pits in their snouts." + }, + { + "fact": "Snakes typically need to eat only 6–30 meals each year to be healthy." + }, + { + "fact": "Snakes like to lie on roads and rocky areas because stones and rocks absorb heat from the sun, which warms them. Basking on these surfaces warms a snake quickly so it can move. If the temperature reaches below 50° Fahrenheit, a snake’s body does not work properly." + }, + { + "fact": "The Mozambique spitting cobra can spit venom over 8 feet away. It can spit from any position, including lying on the ground or raised up. It prefers to aim for its victim’s eyes." + }, + { + "fact": "Snakes cannot chew, so they must swallow their food whole. They are able to stretch their mouths very wide because they have a very flexible lower jaw. Snakes can eat other animals that are 75%–100% bigger than their own bodies." + }, + { + "fact": "To keep from choking on large prey, a snake will push the end of its trachea, or windpipe, out of its mouth, similar to the way a snorkel works." + }, + { + "fact": "The Gaboon viper has the longest fangs of any snake, reaching about 2 inches (5 cm) long." + }, + { + "fact": "Anacondas can hold their breath for up to 10 minutes under water. Additionally, similar to crocodiles, anacondas have eyes and nostrils that can poke above the water’s surface to increase their stealth and hunting prowess." + }, + { + "fact": "The longest snake ever recorded is the reticulated python. It can reach over 33 feet long, which is big enough to swallow a pig, a deer, or even a person." + }, + { + "fact": "Sea snakes with their paddle-shaped tails can dive over 300 feet into the ocean." + }, + { + "fact": "If a snake is threatened soon after a meal, it will often regurgitate its food so it can quickly escape the perceived threat. A snake’s digestive system can dissolve everything but a prey’s hair, feathers, and claws." + }, + { + "fact": "Snakes do not have eyelids; rather, a single transparent scale called a brille protects their eyes. Most snakes see very well, especially if the object is moving." + }, + { + "fact": "The world’s longest venomous snake is the king cobra from Asia. It can grow up to 18 feet, rear almost as high as a person, growl loudly, and inject enough venom to kill an elephant." + }, + { + "fact": "The king cobra is thought to be one of the most intelligent of all snakes. Additionally, unlike most snakes, who do not care for their young, king cobras are careful parents who defend and protect their eggs from enemies." + }, + { + "fact": "Not all snakes have fangs—only those that kill their prey with venom have them. When their fangs are not in use, they fold them back into the roof of the mouth (except for the coral snake, whose fangs do not fold back)." + }, + { + "fact": "Some venomous snakes have died after biting and poisoning themselves by mistake." + }, + { + "fact": "Elephant trunk snakes are almost completely aquatic. They cannot slither because they lack the broad scales in the belly that help other snakes move on land. Rather, elephant trunk snakes have large knobby scales to hold onto slippery fish and constrict them underwater." + }, + { + "fact": "The shortest known snake is the thread snake. It is about 4 inches long and lives on the island of Barbados in the Caribbean. It is said to be as 'thin as spaghetti' and it feeds primarily on termites and larvae." + }, + { + "fact": "In 2009, a farm worker in East Africa survived an epic 3-hour battle with a 12-foot python after accidentally stepping on the large snake. It coiled around the man and carried him into a tree. The man wrapped his shirt over the snake’s mouth to prevent it from swallowing him, and he was finally rescued by police after calling for help on his cell phone." + }, + { + "fact": "The venom from a Brazilian pit viper is used in a drug to treat high blood pressure." + }, + { + "fact": "The word 'cobra' means 'hooded.' Some cobras have large spots on the back of their hood that look like eyes to make them appear intimating even from behind." + }, + { + "fact": "Some desert snakes, such as the African rock python, sleep during the hottest parts of the desert summer. This summer sleep is similar to hibernation and is called “aestivation.”" + }, + { + "fact": "The black mamba is the world’s fastest snake and the world’s second-longest venomous snake in the world, after the king cobra. Found in East Africa, it can reach speeds of up to 12 mph (19kph). It’s named not from the color of its scales, which is olive green, but from the inside of its mouth, which is inky black. Its venom is highly toxic, and without anti-venom, death in humans usually occurs within 7–15 hours." + }, + { + "fact": "Although a snake’s growth rate slows as it gets older, a snake never stops growing." + }, + { + "fact": "While a snake cannot hear the music of a snake charmer, the snake responds to the vibrations of the charmer’s tapping foot or to the movement of the flute." + }, + { + "fact": "Most snakes are not harmful to humans and they help balance the ecosystem by keeping the population of rats, mice, and birds under control." + }, + { + "fact": "The largest snake fossil ever found is the Titanoboa. It lived over 60 million years ago and reached over 50 feet (15 meters) long. It weighed more than 20 people and ate crocodiles and giant tortoises." + }, + { + "fact": "Two-headed snakes are similar to conjoined twins: an embryo begins to split to create identical twins, but the process does not finish. Such snakes rarely survive in the wild because the two heads have duplicate senses, they fight over food, and one head may try to eat the other head." + }, + { + "fact": "Snakes can be grouped into two sections: primitive snakes and true (typical) snakes. Primitive snakes—such as blind snakes, worm snakes, and thread snakes—represent the earliest forms of snakes. True snakes, such as rat snakes and king snakes, are more evolved and more active." + }, + { + "fact": "The oldest written record that describes snakes is in the Brooklyn Papyrus, which is a medical papyrus dating from ancient Egypt (450 B.C.)." + }, + { + "fact": "Approximately 70% of snakes lay eggs. Those that lay eggs are called oviparous. The other 30% of snakes live in colder climates and give birth to live young because it is too cold for eggs outside the body to develop and hatch." + }, + { + "fact": "Most snakes have an elongated right lung, many have a smaller left lung, and a few even have a third lung. They do not have a sense of taste, and most of their organs are organized linearly." + }, + { + "fact": "The most rare and endangered snake is the St. Lucia racer. There are only 18 to 100 of these snakes left." + }, + { + "fact": "Snakes kill over 40,000 people a year—though, with unreported incidents, the total may be over 100,000. About half of these deaths are in India." + }, + { + "fact": "In some cultures, eating snakes is considered a delicacy. For example, snake soup has been a popular Cantonese delicacy for over 2,000 years." + }, + { + "fact": "In some Asian countries, it is believed that drinking the blood of snakes, particularly the cobra, will increase sexual virility. The blood is usually drained from a live snake and then mixed with liquor." + }, + { + "fact": "In the United States, fewer than 1 in 37,500 people are bitten by venomous snakes each year (7,000–8,000 bites per year), and only 1 in 50 million people will die from snake bite (5–6 fatalities per year). In the U.S., a person is 9 times more likely to die from being struck by lightening than to die from a venomous snakebite." + }, + { + "fact": "Some members of the U.S. Army Special Forces are taught to kill and eat snakes during their survival training, which has earned them the nickname 'Snake Eaters.'" + }, + { + "fact": "One of the great feats of the legendary Greek hero Perseus was to kill Medusa, a female monster whose hair consisted of writhing, venomous snakes." + }, + { + "fact": "The symbol of the snake is one of the most widespread and oldest cultural symbols in history. Snakes often represent the duality of good and evil and of life and death." + }, + { + "fact": "Because snakes shed their skin, they are often symbols of rebirth, transformation, and healing. For example, Asclepius, the god of medicine, carries a staff encircled by a snake." + }, + { + "fact": "The snake has held various meanings throughout history. For example, The Egyptians viewed the snake as representing royalty and deity. In the Jewish rabbinical tradition and in Hinduism, it represents sexual passion and desire. And the Romans interpreted the snake as a symbol of eternal love." + }, + { + "fact": "Anacondas mate in a huge 'breeding ball.' The ball consists of 1 female and nearly 12 males. They stay in a 'mating ball' for up to a month." + }, + { + "fact": "Depending on the species, snakes can live from 4 to over 25 years." + }, + { + "fact": "Snakes that are poisonous have pupils that are shaped like a diamond. Nonpoisonous snakes have round pupils." + }, + { + "fact": "Endangered snakes include the San Francisco garter snake, eastern indigo snake, the king cobra, and Dumeril’s boa." + }, + { + "fact": "A mysterious, new 'mad snake disease' causes captive pythons and boas to tie themselves in knots. Other symptoms include 'stargazing,' which is when snakes stare upwards for long periods of time. Snake experts believe a rodent virus causes the fatal disease." + } +] diff --git a/bot/resources/fun/snakes/snake_idioms.json b/bot/resources/fun/snakes/snake_idioms.json new file mode 100644 index 00000000..ecbeb6ff --- /dev/null +++ b/bot/resources/fun/snakes/snake_idioms.json @@ -0,0 +1,275 @@ +[ + { + "idiom": "snek it up" + }, + { + "idiom": "get ur snek on" + }, + { + "idiom": "snek ur heart out" + }, + { + "idiom": "snek 4 ever" + }, + { + "idiom": "i luve snek" + }, + { + "idiom": "snek bff" + }, + { + "idiom": "boyfriend snek" + }, + { + "idiom": "dont snek ur homies" + }, + { + "idiom": "garden snek" + }, + { + "idiom": "snektie" + }, + { + "idiom": "snek keks" + }, + { + "idiom": "birthday snek!" + }, + { + "idiom": "snek tonight?" + }, + { + "idiom": "snek hott lips" + }, + { + "idiom": "snek u latr" + }, + { + "idiom": "netflx and snek" + }, + { + "idiom": "holy snek prey4u" + }, + { + "idiom": "ghowst snek hauntt u" + }, + { + "idiom": "ipekek snek syrop" + }, + { + "idiom": "2 snek 2 furius" + }, + { + "idiom": "the shawsnek redumpton" + }, + { + "idiom": "snekler's list" + }, + { + "idiom": "snekablanca" + }, + { + "idiom": "romeo n snekulet" + }, + { + "idiom": "citizn snek" + }, + { + "idiom": "gon wit the snek" + }, + { + "idiom": "dont step on snek" + }, + { + "idiom": "the wizrd uf snek" + }, + { + "idiom": "forrest snek" + }, + { + "idiom": "snek of musik" + }, + { + "idiom": "west snek story" + }, + { + "idiom": "snek wars eposide XI" + }, + { + "idiom": "2001: a snek odyssuuy" + }, + { + "idiom": "E.T. the snekstra terrastriul" + }, + { + "idiom": "snekkin' inth rain" + }, + { + "idiom": "dr sneklove" + }, + { + "idiom": "snekley kubrik" + }, + { + "idiom": "willium snekspeare" + }, + { + "idiom": "snek on tutanic" + }, + { + "idiom": "a snekwork orunge" + }, + { + "idiom": "the snek the bad n the ogly" + }, + { + "idiom": "the sneksorcist" + }, + { + "idiom": "gudd snek huntin" + }, + { + "idiom": "leonurdo disnekrio" + }, + { + "idiom": "denzal snekington" + }, + { + "idiom": "snekuel l jocksons" + }, + { + "idiom": "kevn snek" + }, + { + "idiom": "snekthony hopkuns" + }, + { + "idiom": "hugh snekman" + }, + { + "idiom": "snek but it glow in durk" + }, + { + "idiom": "snek but u cn ride it" + }, + { + "idiom": "snek but slep in ur bed" + }, + { + "idiom": "snek but mad frum plastk" + }, + { + "idiom": "snek but bulong 2 ur frnd" + }, + { + "idiom": "sneks on plene" + }, + { + "idiom": "baby snek" + }, + { + "idiom": "trouser snek" + }, + { + "idiom": "momo snek" + }, + { + "idiom": "fast snek" + }, + { + "idiom": "super slow snek" + }, + { + "idiom": "old snek" + }, + { + "idiom": "slimy snek" + }, + { + "idiom": "snek attekk" + }, + { + "idiom": "snek get wrekk" + }, + { + "idiom": "snek you long time" + }, + { + "idiom": "carpenter snek" + }, + { + "idiom": "drain snek" + }, + { + "idiom": "eat ur face snek" + }, + { + "idiom": "kawaii snek" + }, + { + "idiom": "dis snek is soft" + }, + { + "idiom": "snek is 4 yers uld" + }, + { + "idiom": "pls feed snek, is hingry" + }, + { + "idiom": "snek? snek? sneeeeek!!" + }, + { + "idiom": "solid snek" + }, + { + "idiom": "big bos snek" + }, + { + "idiom": "snek republic" + }, + { + "idiom": "snekoslovakia" + }, + { + "idiom": "snek please!" + }, + { + "idiom": "i brok my snek :(" + }, + { + "idiom": "star snek the nxt generatin" + }, + { + "idiom": "azsnek tempul" + }, + { + "idiom": "discosnek" + }, + { + "idiom": "bottlsnek" + }, + { + "idiom": "turtlsnek" + }, + { + "idiom": "cashiers snek" + }, + { + "idiom": "mega snek!!" + }, + { + "idiom": "one tim i saw snek neked" + }, + { + "idiom": "snek cnt clim trees" + }, + { + "idiom": "snek in muth is jus tongue" + }, + { + "idiom": "juan snek" + }, + { + "idiom": "photosnek" + } +] diff --git a/bot/resources/fun/snakes/snake_names.json b/bot/resources/fun/snakes/snake_names.json new file mode 100644 index 00000000..25832550 --- /dev/null +++ b/bot/resources/fun/snakes/snake_names.json @@ -0,0 +1,2170 @@ +[ + { + "name": "Acanthophis", + "scientific": "Acanthophis" + }, + { + "name": "Aesculapian snake", + "scientific": "Aesculapian snake" + }, + { + "name": "African beaked snake", + "scientific": "Rufous beaked snake" + }, + { + "name": "African puff adder", + "scientific": "Bitis arietans" + }, + { + "name": "African rock python", + "scientific": "African rock python" + }, + { + "name": "African twig snake", + "scientific": "Twig snake" + }, + { + "name": "Agkistrodon piscivorus", + "scientific": "Agkistrodon piscivorus" + }, + { + "name": "Ahaetulla", + "scientific": "Ahaetulla" + }, + { + "name": "Amazonian palm viper", + "scientific": "Bothriopsis bilineata" + }, + { + "name": "American copperhead", + "scientific": "Agkistrodon contortrix" + }, + { + "name": "Amethystine python", + "scientific": "Amethystine python" + }, + { + "name": "Anaconda", + "scientific": "Anaconda" + }, + { + "name": "Andaman cat snake", + "scientific": "Boiga andamanensis" + }, + { + "name": "Andrea's keelback", + "scientific": "Amphiesma andreae" + }, + { + "name": "Annulated sea snake", + "scientific": "Hydrophis cyanocinctus" + }, + { + "name": "Arafura file snake", + "scientific": "Acrochordus arafurae" + }, + { + "name": "Arizona black rattlesnake", + "scientific": "Crotalus oreganus cerberus" + }, + { + "name": "Arizona coral snake", + "scientific": "Coral snake" + }, + { + "name": "Aruba rattlesnake", + "scientific": "Crotalus durissus unicolor" + }, + { + "name": "Asian cobra", + "scientific": "Indian cobra" + }, + { + "name": "Asian keelback", + "scientific": "Amphiesma vibakari" + }, + { + "name": "Asp (reptile)", + "scientific": "Asp (reptile)" + }, + { + "name": "Assam keelback", + "scientific": "Amphiesma pealii" + }, + { + "name": "Australian copperhead", + "scientific": "Austrelaps" + }, + { + "name": "Australian scrub python", + "scientific": "Amethystine python" + }, + { + "name": "Baird's rat snake", + "scientific": "Pantherophis bairdi" + }, + { + "name": "Banded Flying Snake", + "scientific": "Banded flying snake" + }, + { + "name": "Banded cat-eyed snake", + "scientific": "Banded cat-eyed snake" + }, + { + "name": "Banded krait", + "scientific": "Banded krait" + }, + { + "name": "Barred wolf snake", + "scientific": "Lycodon striatus" + }, + { + "name": "Beaked sea snake", + "scientific": "Enhydrina schistosa" + }, + { + "name": "Beauty rat snake", + "scientific": "Beauty rat snake" + }, + { + "name": "Beddome's cat snake", + "scientific": "Boiga beddomei" + }, + { + "name": "Beddome's coral snake", + "scientific": "Beddome's coral snake" + }, + { + "name": "Bird snake", + "scientific": "Twig snake" + }, + { + "name": "Black-banded trinket snake", + "scientific": "Oreocryptophis porphyraceus" + }, + { + "name": "Black-headed snake", + "scientific": "Western black-headed snake" + }, + { + "name": "Black-necked cobra", + "scientific": "Black-necked spitting cobra" + }, + { + "name": "Black-necked spitting cobra", + "scientific": "Black-necked spitting cobra" + }, + { + "name": "Black-striped keelback", + "scientific": "Buff striped keelback" + }, + { + "name": "Black-tailed horned pit viper", + "scientific": "Mixcoatlus melanurus" + }, + { + "name": "Black headed python", + "scientific": "Black-headed python" + }, + { + "name": "Black krait", + "scientific": "Greater black krait" + }, + { + "name": "Black mamba", + "scientific": "Black mamba" + }, + { + "name": "Black rat snake", + "scientific": "Rat snake" + }, + { + "name": "Black tree cobra", + "scientific": "Cobra" + }, + { + "name": "Blind snake", + "scientific": "Scolecophidia" + }, + { + "name": "Blonde hognose snake", + "scientific": "Hognose" + }, + { + "name": "Blood python", + "scientific": "Python brongersmai" + }, + { + "name": "Blue krait", + "scientific": "Bungarus candidus" + }, + { + "name": "Blunt-headed tree snake", + "scientific": "Imantodes cenchoa" + }, + { + "name": "Boa constrictor", + "scientific": "Boa constrictor" + }, + { + "name": "Bocourt's water snake", + "scientific": "Subsessor" + }, + { + "name": "Boelen python", + "scientific": "Morelia boeleni" + }, + { + "name": "Boidae", + "scientific": "Boidae" + }, + { + "name": "Boiga", + "scientific": "Boiga" + }, + { + "name": "Boomslang", + "scientific": "Boomslang" + }, + { + "name": "Brahminy blind snake", + "scientific": "Indotyphlops braminus" + }, + { + "name": "Brazilian coral snake", + "scientific": "Coral snake" + }, + { + "name": "Brazilian smooth snake", + "scientific": "Hydrodynastes gigas" + }, + { + "name": "Brown snake (disambiguation)", + "scientific": "Brown snake" + }, + { + "name": "Brown tree snake", + "scientific": "Brown tree snake" + }, + { + "name": "Brown white-lipped python", + "scientific": "Leiopython" + }, + { + "name": "Buff striped keelback", + "scientific": "Buff striped keelback" + }, + { + "name": "Bull snake", + "scientific": "Bull snake" + }, + { + "name": "Burmese keelback", + "scientific": "Burmese keelback water snake" + }, + { + "name": "Burmese krait", + "scientific": "Burmese krait" + }, + { + "name": "Burmese python", + "scientific": "Burmese python" + }, + { + "name": "Burrowing viper", + "scientific": "Atractaspidinae" + }, + { + "name": "Buttermilk racer", + "scientific": "Coluber constrictor anthicus" + }, + { + "name": "California kingsnake", + "scientific": "California kingsnake" + }, + { + "name": "Cantor's pitviper", + "scientific": "Trimeresurus cantori" + }, + { + "name": "Cape cobra", + "scientific": "Cape cobra" + }, + { + "name": "Cape coral snake", + "scientific": "Aspidelaps lubricus" + }, + { + "name": "Cape gopher snake", + "scientific": "Cape gopher snake" + }, + { + "name": "Carpet viper", + "scientific": "Echis" + }, + { + "name": "Cat-eyed night snake", + "scientific": "Banded cat-eyed snake" + }, + { + "name": "Cat-eyed snake", + "scientific": "Banded cat-eyed snake" + }, + { + "name": "Cat snake", + "scientific": "Boiga" + }, + { + "name": "Central American lyre snake", + "scientific": "Trimorphodon biscutatus" + }, + { + "name": "Central ranges taipan", + "scientific": "Taipan" + }, + { + "name": "Chappell Island tiger snake", + "scientific": "Tiger snake" + }, + { + "name": "Checkered garter snake", + "scientific": "Checkered garter snake" + }, + { + "name": "Checkered keelback", + "scientific": "Checkered keelback" + }, + { + "name": "Children's python", + "scientific": "Children's python" + }, + { + "name": "Chinese cobra", + "scientific": "Chinese cobra" + }, + { + "name": "Coachwhip snake", + "scientific": "Masticophis flagellum" + }, + { + "name": "Coastal taipan", + "scientific": "Coastal taipan" + }, + { + "name": "Cobra", + "scientific": "Cobra" + }, + { + "name": "Collett's snake", + "scientific": "Collett's snake" + }, + { + "name": "Common adder", + "scientific": "Vipera berus" + }, + { + "name": "Common cobra", + "scientific": "Chinese cobra" + }, + { + "name": "Common garter snake", + "scientific": "Common garter snake" + }, + { + "name": "Common ground snake", + "scientific": "Western ground snake" + }, + { + "name": "Common keelback (disambiguation)", + "scientific": "Common keelback" + }, + { + "name": "Common tiger snake", + "scientific": "Tiger snake" + }, + { + "name": "Common worm snake", + "scientific": "Indotyphlops braminus" + }, + { + "name": "Congo snake", + "scientific": "Amphiuma" + }, + { + "name": "Congo water cobra", + "scientific": "Naja christyi" + }, + { + "name": "Coral snake", + "scientific": "Coral snake" + }, + { + "name": "Corn snake", + "scientific": "Corn snake" + }, + { + "name": "Coronado Island rattlesnake", + "scientific": "Crotalus oreganus caliginis" + }, + { + "name": "Crossed viper", + "scientific": "Vipera berus" + }, + { + "name": "Crotalus cerastes", + "scientific": "Crotalus cerastes" + }, + { + "name": "Crotalus durissus", + "scientific": "Crotalus durissus" + }, + { + "name": "Crotalus horridus", + "scientific": "Timber rattlesnake" + }, + { + "name": "Crowned snake", + "scientific": "Tantilla" + }, + { + "name": "Cuban boa", + "scientific": "Chilabothrus angulifer" + }, + { + "name": "Cuban wood snake", + "scientific": "Tropidophis melanurus" + }, + { + "name": "Dasypeltis", + "scientific": "Dasypeltis" + }, + { + "name": "Desert death adder", + "scientific": "Desert death adder" + }, + { + "name": "Desert kingsnake", + "scientific": "Desert kingsnake" + }, + { + "name": "Desert woma python", + "scientific": "Woma python" + }, + { + "name": "Diamond python", + "scientific": "Morelia spilota spilota" + }, + { + "name": "Dog-toothed cat snake", + "scientific": "Boiga cynodon" + }, + { + "name": "Down's tiger snake", + "scientific": "Tiger snake" + }, + { + "name": "Dubois's sea snake", + "scientific": "Aipysurus duboisii" + }, + { + "name": "Durango rock rattlesnake", + "scientific": "Crotalus lepidus klauberi" + }, + { + "name": "Dusty hognose snake", + "scientific": "Hognose" + }, + { + "name": "Dwarf beaked snake", + "scientific": "Dwarf beaked snake" + }, + { + "name": "Dwarf boa", + "scientific": "Boa constrictor" + }, + { + "name": "Dwarf pipe snake", + "scientific": "Anomochilus" + }, + { + "name": "Eastern brown snake", + "scientific": "Eastern brown snake" + }, + { + "name": "Eastern coral snake", + "scientific": "Micrurus fulvius" + }, + { + "name": "Eastern diamondback rattlesnake", + "scientific": "Eastern diamondback rattlesnake" + }, + { + "name": "Eastern green mamba", + "scientific": "Eastern green mamba" + }, + { + "name": "Eastern hognose snake", + "scientific": "Eastern hognose snake" + }, + { + "name": "Eastern mud snake", + "scientific": "Mud snake" + }, + { + "name": "Eastern racer", + "scientific": "Coluber constrictor" + }, + { + "name": "Eastern tiger snake", + "scientific": "Tiger snake" + }, + { + "name": "Eastern water cobra", + "scientific": "Cobra" + }, + { + "name": "Elaps harlequin snake", + "scientific": "Micrurus fulvius" + }, + { + "name": "Eunectes", + "scientific": "Eunectes" + }, + { + "name": "European Smooth Snake", + "scientific": "Smooth snake" + }, + { + "name": "False cobra", + "scientific": "False cobra" + }, + { + "name": "False coral snake", + "scientific": "Coral snake" + }, + { + "name": "False water cobra", + "scientific": "Hydrodynastes gigas" + }, + { + "name": "Fierce snake", + "scientific": "Inland taipan" + }, + { + "name": "Flying snake", + "scientific": "Chrysopelea" + }, + { + "name": "Forest cobra", + "scientific": "Forest cobra" + }, + { + "name": "Forsten's cat snake", + "scientific": "Boiga forsteni" + }, + { + "name": "Fox snake", + "scientific": "Fox snake" + }, + { + "name": "Gaboon viper", + "scientific": "Gaboon viper" + }, + { + "name": "Garter snake", + "scientific": "Garter snake" + }, + { + "name": "Giant Malagasy hognose snake", + "scientific": "Hognose" + }, + { + "name": "Glossy snake", + "scientific": "Glossy snake" + }, + { + "name": "Gold-ringed cat snake", + "scientific": "Boiga dendrophila" + }, + { + "name": "Gold tree cobra", + "scientific": "Pseudohaje goldii" + }, + { + "name": "Golden tree snake", + "scientific": "Chrysopelea ornata" + }, + { + "name": "Gopher snake", + "scientific": "Pituophis catenifer" + }, + { + "name": "Grand Canyon rattlesnake", + "scientific": "Crotalus oreganus abyssus" + }, + { + "name": "Grass snake", + "scientific": "Grass snake" + }, + { + "name": "Gray cat snake", + "scientific": "Boiga ocellata" + }, + { + "name": "Great Plains rat snake", + "scientific": "Pantherophis emoryi" + }, + { + "name": "Green anaconda", + "scientific": "Green anaconda" + }, + { + "name": "Green rat snake", + "scientific": "Rat snake" + }, + { + "name": "Green tree python", + "scientific": "Green tree python" + }, + { + "name": "Grey-banded kingsnake", + "scientific": "Gray-banded kingsnake" + }, + { + "name": "Grey Lora", + "scientific": "Leptophis stimsoni" + }, + { + "name": "Halmahera python", + "scientific": "Morelia tracyae" + }, + { + "name": "Harlequin coral snake", + "scientific": "Micrurus fulvius" + }, + { + "name": "Herald snake", + "scientific": "Caduceus" + }, + { + "name": "High Woods coral snake", + "scientific": "Coral snake" + }, + { + "name": "Hill keelback", + "scientific": "Amphiesma monticola" + }, + { + "name": "Himalayan keelback", + "scientific": "Amphiesma platyceps" + }, + { + "name": "Hognose snake", + "scientific": "Hognose" + }, + { + "name": "Hognosed viper", + "scientific": "Porthidium" + }, + { + "name": "Hook Nosed Sea Snake", + "scientific": "Enhydrina schistosa" + }, + { + "name": "Hoop snake", + "scientific": "Hoop snake" + }, + { + "name": "Hopi rattlesnake", + "scientific": "Crotalus viridis nuntius" + }, + { + "name": "Indian cobra", + "scientific": "Indian cobra" + }, + { + "name": "Indian egg-eater", + "scientific": "Indian egg-eating snake" + }, + { + "name": "Indian flying snake", + "scientific": "Chrysopelea ornata" + }, + { + "name": "Indian krait", + "scientific": "Bungarus" + }, + { + "name": "Indigo snake", + "scientific": "Drymarchon" + }, + { + "name": "Inland carpet python", + "scientific": "Morelia spilota metcalfei" + }, + { + "name": "Inland taipan", + "scientific": "Inland taipan" + }, + { + "name": "Jamaican boa", + "scientific": "Jamaican boa" + }, + { + "name": "Jan's hognose snake", + "scientific": "Hognose" + }, + { + "name": "Japanese forest rat snake", + "scientific": "Euprepiophis conspicillatus" + }, + { + "name": "Japanese rat snake", + "scientific": "Japanese rat snake" + }, + { + "name": "Japanese striped snake", + "scientific": "Japanese striped snake" + }, + { + "name": "Kayaudi dwarf reticulated python", + "scientific": "Reticulated python" + }, + { + "name": "Keelback", + "scientific": "Natricinae" + }, + { + "name": "Khasi Hills keelback", + "scientific": "Amphiesma khasiense" + }, + { + "name": "King Island tiger snake", + "scientific": "Tiger snake" + }, + { + "name": "King brown", + "scientific": "Mulga snake" + }, + { + "name": "King cobra", + "scientific": "King cobra" + }, + { + "name": "King rat snake", + "scientific": "Rat snake" + }, + { + "name": "King snake", + "scientific": "Kingsnake" + }, + { + "name": "Krait", + "scientific": "Bungarus" + }, + { + "name": "Krefft's tiger snake", + "scientific": "Tiger snake" + }, + { + "name": "Lance-headed rattlesnake", + "scientific": "Crotalus polystictus" + }, + { + "name": "Lancehead", + "scientific": "Bothrops" + }, + { + "name": "Large shield snake", + "scientific": "Pseudotyphlops" + }, + { + "name": "Leptophis ahaetulla", + "scientific": "Leptophis ahaetulla" + }, + { + "name": "Lesser black krait", + "scientific": "Lesser black krait" + }, + { + "name": "Long-nosed adder", + "scientific": "Eastern hognose snake" + }, + { + "name": "Long-nosed tree snake", + "scientific": "Western hognose snake" + }, + { + "name": "Long-nosed whip snake", + "scientific": "Ahaetulla nasuta" + }, + { + "name": "Long-tailed rattlesnake", + "scientific": "Rattlesnake" + }, + { + "name": "Longnosed worm snake", + "scientific": "Leptotyphlops macrorhynchus" + }, + { + "name": "Lyre snake", + "scientific": "Trimorphodon" + }, + { + "name": "Madagascar ground boa", + "scientific": "Acrantophis madagascariensis" + }, + { + "name": "Malayan krait", + "scientific": "Bungarus candidus" + }, + { + "name": "Malayan long-glanded coral snake", + "scientific": "Calliophis bivirgata" + }, + { + "name": "Malayan pit viper", + "scientific": "Pit viper" + }, + { + "name": "Mamba", + "scientific": "Mamba" + }, + { + "name": "Mamushi", + "scientific": "Mamushi" + }, + { + "name": "Manchurian Black Water Snake", + "scientific": "Elaphe schrenckii" + }, + { + "name": "Mandarin rat snake", + "scientific": "Mandarin rat snake" + }, + { + "name": "Mangrove snake (disambiguation)", + "scientific": "Mangrove snake" + }, + { + "name": "Many-banded krait", + "scientific": "Many-banded krait" + }, + { + "name": "Many-banded tree snake", + "scientific": "Many-banded tree snake" + }, + { + "name": "Many-spotted cat snake", + "scientific": "Boiga multomaculata" + }, + { + "name": "Massasauga rattlesnake", + "scientific": "Massasauga" + }, + { + "name": "Mexican black kingsnake", + "scientific": "Mexican black kingsnake" + }, + { + "name": "Mexican green rattlesnake", + "scientific": "Crotalus basiliscus" + }, + { + "name": "Mexican hognose snake", + "scientific": "Hognose" + }, + { + "name": "Mexican parrot snake", + "scientific": "Leptophis mexicanus" + }, + { + "name": "Mexican racer", + "scientific": "Coluber constrictor oaxaca" + }, + { + "name": "Mexican vine snake", + "scientific": "Oxybelis aeneus" + }, + { + "name": "Mexican west coast rattlesnake", + "scientific": "Crotalus basiliscus" + }, + { + "name": "Micropechis ikaheka", + "scientific": "Micropechis ikaheka" + }, + { + "name": "Midget faded rattlesnake", + "scientific": "Crotalus oreganus concolor" + }, + { + "name": "Milk snake", + "scientific": "Milk snake" + }, + { + "name": "Moccasin snake", + "scientific": "Agkistrodon piscivorus" + }, + { + "name": "Modest keelback", + "scientific": "Amphiesma modestum" + }, + { + "name": "Mojave desert sidewinder", + "scientific": "Crotalus cerastes" + }, + { + "name": "Mojave rattlesnake", + "scientific": "Crotalus scutulatus" + }, + { + "name": "Mole viper", + "scientific": "Atractaspidinae" + }, + { + "name": "Moluccan flying snake", + "scientific": "Chrysopelea" + }, + { + "name": "Montpellier snake", + "scientific": "Malpolon monspessulanus" + }, + { + "name": "Mud adder", + "scientific": "Mud adder" + }, + { + "name": "Mud snake", + "scientific": "Mud snake" + }, + { + "name": "Mussurana", + "scientific": "Mussurana" + }, + { + "name": "Narrowhead Garter Snake", + "scientific": "Garter snake" + }, + { + "name": "Nicobar Island keelback", + "scientific": "Amphiesma nicobariense" + }, + { + "name": "Nicobar cat snake", + "scientific": "Boiga wallachi" + }, + { + "name": "Night snake", + "scientific": "Night snake" + }, + { + "name": "Nilgiri keelback", + "scientific": "Nilgiri keelback" + }, + { + "name": "North eastern king snake", + "scientific": "Eastern hognose snake" + }, + { + "name": "Northeastern hill krait", + "scientific": "Northeastern hill krait" + }, + { + "name": "Northern black-tailed rattlesnake", + "scientific": "Crotalus molossus" + }, + { + "name": "Northern tree snake", + "scientific": "Dendrelaphis calligastra" + }, + { + "name": "Northern water snake", + "scientific": "Northern water snake" + }, + { + "name": "Northern white-lipped python", + "scientific": "Leiopython" + }, + { + "name": "Oaxacan small-headed rattlesnake", + "scientific": "Crotalus intermedius gloydi" + }, + { + "name": "Okinawan habu", + "scientific": "Okinawan habu" + }, + { + "name": "Olive sea snake", + "scientific": "Aipysurus laevis" + }, + { + "name": "Opheodrys", + "scientific": "Opheodrys" + }, + { + "name": "Orange-collared keelback", + "scientific": "Rhabdophis himalayanus" + }, + { + "name": "Ornate flying snake", + "scientific": "Chrysopelea ornata" + }, + { + "name": "Oxybelis", + "scientific": "Oxybelis" + }, + { + "name": "Palestine viper", + "scientific": "Vipera palaestinae" + }, + { + "name": "Paradise flying snake", + "scientific": "Chrysopelea paradisi" + }, + { + "name": "Parrot snake", + "scientific": "Leptophis ahaetulla" + }, + { + "name": "Patchnose snake", + "scientific": "Salvadora (snake)" + }, + { + "name": "Pelagic sea snake", + "scientific": "Yellow-bellied sea snake" + }, + { + "name": "Peninsula tiger snake", + "scientific": "Tiger snake" + }, + { + "name": "Perrotet's shieldtail snake", + "scientific": "Plectrurus perrotetii" + }, + { + "name": "Persian rat snake", + "scientific": "Rat snake" + }, + { + "name": "Pine snake", + "scientific": "Pine snake" + }, + { + "name": "Pit viper", + "scientific": "Pit viper" + }, + { + "name": "Plains hognose snake", + "scientific": "Western hognose snake" + }, + { + "name": "Prairie kingsnake", + "scientific": "Lampropeltis calligaster" + }, + { + "name": "Pygmy python", + "scientific": "Pygmy python" + }, + { + "name": "Pythonidae", + "scientific": "Pythonidae" + }, + { + "name": "Queen snake", + "scientific": "Queen snake" + }, + { + "name": "Rat snake", + "scientific": "Rat snake" + }, + { + "name": "Rattler", + "scientific": "Rattlesnake" + }, + { + "name": "Rattlesnake", + "scientific": "Rattlesnake" + }, + { + "name": "Red-bellied black snake", + "scientific": "Red-bellied black snake" + }, + { + "name": "Red-headed krait", + "scientific": "Red-headed krait" + }, + { + "name": "Red-necked keelback", + "scientific": "Rhabdophis subminiatus" + }, + { + "name": "Red-tailed bamboo pitviper", + "scientific": "Trimeresurus erythrurus" + }, + { + "name": "Red-tailed boa", + "scientific": "Boa constrictor" + }, + { + "name": "Red-tailed pipe snake", + "scientific": "Cylindrophis ruffus" + }, + { + "name": "Red blood python", + "scientific": "Python brongersmai" + }, + { + "name": "Red diamond rattlesnake", + "scientific": "Crotalus ruber" + }, + { + "name": "Reticulated python", + "scientific": "Reticulated python" + }, + { + "name": "Ribbon snake", + "scientific": "Ribbon snake" + }, + { + "name": "Ringed hognose snake", + "scientific": "Hognose" + }, + { + "name": "Rosy boa", + "scientific": "Rosy boa" + }, + { + "name": "Rough green snake", + "scientific": "Opheodrys aestivus" + }, + { + "name": "Rubber boa", + "scientific": "Rubber boa" + }, + { + "name": "Rufous beaked snake", + "scientific": "Rufous beaked snake" + }, + { + "name": "Russell's viper", + "scientific": "Russell's viper" + }, + { + "name": "San Francisco garter snake", + "scientific": "San Francisco garter snake" + }, + { + "name": "Sand boa", + "scientific": "Erycinae" + }, + { + "name": "Sand viper", + "scientific": "Sand viper" + }, + { + "name": "Saw-scaled viper", + "scientific": "Echis" + }, + { + "name": "Scarlet kingsnake", + "scientific": "Scarlet kingsnake" + }, + { + "name": "Sea snake", + "scientific": "Hydrophiinae" + }, + { + "name": "Selayer reticulated python", + "scientific": "Reticulated python" + }, + { + "name": "Shield-nosed cobra", + "scientific": "Shield-nosed cobra" + }, + { + "name": "Shield-tailed snake", + "scientific": "Uropeltidae" + }, + { + "name": "Sikkim keelback", + "scientific": "Sikkim keelback" + }, + { + "name": "Sind krait", + "scientific": "Sind krait" + }, + { + "name": "Smooth green snake", + "scientific": "Smooth green snake" + }, + { + "name": "South American hognose snake", + "scientific": "Hognose" + }, + { + "name": "South Andaman krait", + "scientific": "South Andaman krait" + }, + { + "name": "South eastern corn snake", + "scientific": "Corn snake" + }, + { + "name": "Southern Pacific rattlesnake", + "scientific": "Crotalus oreganus helleri" + }, + { + "name": "Southern black racer", + "scientific": "Southern black racer" + }, + { + "name": "Southern hognose snake", + "scientific": "Southern hognose snake" + }, + { + "name": "Southern white-lipped python", + "scientific": "Leiopython" + }, + { + "name": "Southwestern blackhead snake", + "scientific": "Tantilla hobartsmithi" + }, + { + "name": "Southwestern carpet python", + "scientific": "Morelia spilota imbricata" + }, + { + "name": "Southwestern speckled rattlesnake", + "scientific": "Crotalus mitchellii pyrrhus" + }, + { + "name": "Speckled hognose snake", + "scientific": "Hognose" + }, + { + "name": "Speckled kingsnake", + "scientific": "Lampropeltis getula holbrooki" + }, + { + "name": "Spectacled cobra", + "scientific": "Indian cobra" + }, + { + "name": "Sri Lanka cat snake", + "scientific": "Boiga ceylonensis" + }, + { + "name": "Stiletto snake", + "scientific": "Atractaspidinae" + }, + { + "name": "Stimson's python", + "scientific": "Stimson's python" + }, + { + "name": "Striped snake", + "scientific": "Japanese striped snake" + }, + { + "name": "Sumatran short-tailed python", + "scientific": "Python curtus" + }, + { + "name": "Sunbeam snake", + "scientific": "Xenopeltis" + }, + { + "name": "Taipan", + "scientific": "Taipan" + }, + { + "name": "Tan racer", + "scientific": "Coluber constrictor etheridgei" + }, + { + "name": "Tancitaran dusky rattlesnake", + "scientific": "Crotalus pusillus" + }, + { + "name": "Tanimbar python", + "scientific": "Reticulated python" + }, + { + "name": "Tasmanian tiger snake", + "scientific": "Tiger snake" + }, + { + "name": "Tawny cat snake", + "scientific": "Boiga ochracea" + }, + { + "name": "Temple pit viper", + "scientific": "Pit viper" + }, + { + "name": "Tentacled snake", + "scientific": "Erpeton tentaculatum" + }, + { + "name": "Texas Coral Snake", + "scientific": "Coral snake" + }, + { + "name": "Texas blind snake", + "scientific": "Leptotyphlops dulcis" + }, + { + "name": "Texas garter snake", + "scientific": "Texas garter snake" + }, + { + "name": "Texas lyre snake", + "scientific": "Trimorphodon biscutatus vilkinsonii" + }, + { + "name": "Texas night snake", + "scientific": "Hypsiglena jani" + }, + { + "name": "Thai cobra", + "scientific": "King cobra" + }, + { + "name": "Three-lined ground snake", + "scientific": "Atractus trilineatus" + }, + { + "name": "Tic polonga", + "scientific": "Russell's viper" + }, + { + "name": "Tiger rattlesnake", + "scientific": "Crotalus tigris" + }, + { + "name": "Tiger snake", + "scientific": "Tiger snake" + }, + { + "name": "Tigre snake", + "scientific": "Spilotes pullatus" + }, + { + "name": "Timber rattlesnake", + "scientific": "Timber rattlesnake" + }, + { + "name": "Tree snake", + "scientific": "Brown tree snake" + }, + { + "name": "Tri-color hognose snake", + "scientific": "Hognose" + }, + { + "name": "Trinket snake", + "scientific": "Trinket snake" + }, + { + "name": "Tropical rattlesnake", + "scientific": "Crotalus durissus" + }, + { + "name": "Twig snake", + "scientific": "Twig snake" + }, + { + "name": "Twin-Barred tree snake", + "scientific": "Banded flying snake" + }, + { + "name": "Twin-spotted rat snake", + "scientific": "Rat snake" + }, + { + "name": "Twin-spotted rattlesnake", + "scientific": "Crotalus pricei" + }, + { + "name": "Uracoan rattlesnake", + "scientific": "Crotalus durissus vegrandis" + }, + { + "name": "Viperidae", + "scientific": "Viperidae" + }, + { + "name": "Wall's keelback", + "scientific": "Amphiesma xenura" + }, + { + "name": "Wart snake", + "scientific": "Acrochordidae" + }, + { + "name": "Water adder", + "scientific": "Agkistrodon piscivorus" + }, + { + "name": "Water moccasin", + "scientific": "Agkistrodon piscivorus" + }, + { + "name": "West Indian racer", + "scientific": "Antiguan racer" + }, + { + "name": "Western blind snake", + "scientific": "Leptotyphlops humilis" + }, + { + "name": "Western carpet python", + "scientific": "Morelia spilota" + }, + { + "name": "Western coral snake", + "scientific": "Coral snake" + }, + { + "name": "Western diamondback rattlesnake", + "scientific": "Western diamondback rattlesnake" + }, + { + "name": "Western green mamba", + "scientific": "Western green mamba" + }, + { + "name": "Western ground snake", + "scientific": "Western ground snake" + }, + { + "name": "Western hognose snake", + "scientific": "Western hognose snake" + }, + { + "name": "Western mud snake", + "scientific": "Mud snake" + }, + { + "name": "Western tiger snake", + "scientific": "Tiger snake" + }, + { + "name": "Western woma python", + "scientific": "Woma python" + }, + { + "name": "White-lipped keelback", + "scientific": "Amphiesma leucomystax" + }, + { + "name": "Wolf snake", + "scientific": "Lycodon capucinus" + }, + { + "name": "Woma python", + "scientific": "Woma python" + }, + { + "name": "Wutu", + "scientific": "Bothrops alternatus" + }, + { + "name": "Wynaad keelback", + "scientific": "Amphiesma monticola" + }, + { + "name": "Yellow-banded sea snake", + "scientific": "Yellow-bellied sea snake" + }, + { + "name": "Yellow-bellied sea snake", + "scientific": "Yellow-bellied sea snake" + }, + { + "name": "Yellow-lipped sea snake", + "scientific": "Yellow-lipped sea krait" + }, + { + "name": "Yellow-striped rat snake", + "scientific": "Rat snake" + }, + { + "name": "Yellow anaconda", + "scientific": "Yellow anaconda" + }, + { + "name": "Yellow cobra", + "scientific": "Cape cobra" + }, + { + "name": "Yunnan keelback", + "scientific": "Amphiesma parallelum" + }, + { + "name": "Abaco Island boa", + "scientific": "Epicrates exsul" + }, + { + "name": "Agkistrodon bilineatus", + "scientific": "Agkistrodon bilineatus" + }, + { + "name": "Amazon tree boa", + "scientific": "Corallus hortulanus" + }, + { + "name": "Andaman cobra", + "scientific": "Andaman cobra" + }, + { + "name": "Angolan python", + "scientific": "Python anchietae" + }, + { + "name": "Arabian cobra", + "scientific": "Arabian cobra" + }, + { + "name": "Asp viper", + "scientific": "Vipera aspis" + }, + { + "name": "Ball Python", + "scientific": "Ball python" + }, + { + "name": "Ball python", + "scientific": "Ball python" + }, + { + "name": "Bamboo pitviper", + "scientific": "Trimeresurus gramineus" + }, + { + "name": "Banded pitviper", + "scientific": "Trimeresurus fasciatus" + }, + { + "name": "Banded water cobra", + "scientific": "Naja annulata" + }, + { + "name": "Barbour's pit viper", + "scientific": "Mixcoatlus barbouri" + }, + { + "name": "Bismarck ringed python", + "scientific": "Bothrochilus" + }, + { + "name": "Black-speckled palm-pitviper", + "scientific": "Bothriechis nigroviridis" + }, + { + "name": "Bluntnose viper", + "scientific": "Macrovipera lebetina" + }, + { + "name": "Bornean pitviper", + "scientific": "Trimeresurus borneensis" + }, + { + "name": "Borneo short-tailed python", + "scientific": "Borneo python" + }, + { + "name": "Bothrops jararacussu", + "scientific": "Bothrops jararacussu" + }, + { + "name": "Bredl's python", + "scientific": "Morelia bredli" + }, + { + "name": "Brongersma's pitviper", + "scientific": "Trimeresurus brongersmai" + }, + { + "name": "Brown spotted pitviper", + "scientific": "Trimeresurus mucrosquamatus" + }, + { + "name": "Brown water python", + "scientific": "Liasis fuscus" + }, + { + "name": "Burrowing cobra", + "scientific": "Egyptian cobra" + }, + { + "name": "Bush viper", + "scientific": "Atheris" + }, + { + "name": "Calabar python", + "scientific": "Calabar python" + }, + { + "name": "Caspian cobra", + "scientific": "Caspian cobra" + }, + { + "name": "Centralian carpet python", + "scientific": "Morelia bredli" + }, + { + "name": "Chinese tree viper", + "scientific": "Trimeresurus stejnegeri" + }, + { + "name": "Coastal carpet python", + "scientific": "Morelia spilota mcdowelli" + }, + { + "name": "Colorado desert sidewinder", + "scientific": "Crotalus cerastes laterorepens" + }, + { + "name": "Common lancehead", + "scientific": "Bothrops atrox" + }, + { + "name": "Cyclades blunt-nosed viper", + "scientific": "Macrovipera schweizeri" + }, + { + "name": "Dauan Island water python", + "scientific": "Liasis fuscus" + }, + { + "name": "De Schauensee's anaconda", + "scientific": "Eunectes deschauenseei" + }, + { + "name": "Dumeril's boa", + "scientific": "Acrantophis dumerili" + }, + { + "name": "Dusky pigmy rattlesnake", + "scientific": "Sistrurus miliarius barbouri" + }, + { + "name": "Dwarf sand adder", + "scientific": "Bitis peringueyi" + }, + { + "name": "Egyptian cobra", + "scientific": "Egyptian cobra" + }, + { + "name": "Elegant pitviper", + "scientific": "Trimeresurus elegans" + }, + { + "name": "Emerald tree boa", + "scientific": "Emerald tree boa" + }, + { + "name": "Equatorial spitting cobra", + "scientific": "Equatorial spitting cobra" + }, + { + "name": "European asp", + "scientific": "Vipera aspis" + }, + { + "name": "Eyelash palm-pitviper", + "scientific": "Bothriechis schlegelii" + }, + { + "name": "Eyelash pit viper", + "scientific": "Bothriechis schlegelii" + }, + { + "name": "Eyelash viper", + "scientific": "Bothriechis schlegelii" + }, + { + "name": "False horned viper", + "scientific": "Pseudocerastes" + }, + { + "name": "Fan-Si-Pan horned pitviper", + "scientific": "Trimeresurus cornutus" + }, + { + "name": "Fea's viper", + "scientific": "Azemiops" + }, + { + "name": "Fifty pacer", + "scientific": "Deinagkistrodon" + }, + { + "name": "Flat-nosed pitviper", + "scientific": "Trimeresurus puniceus" + }, + { + "name": "Godman's pit viper", + "scientific": "Cerrophidion godmani" + }, + { + "name": "Great Lakes bush viper", + "scientific": "Atheris nitschei" + }, + { + "name": "Green palm viper", + "scientific": "Bothriechis lateralis" + }, + { + "name": "Green tree pit viper", + "scientific": "Trimeresurus gramineus" + }, + { + "name": "Guatemalan palm viper", + "scientific": "Bothriechis aurifer" + }, + { + "name": "Guatemalan tree viper", + "scientific": "Bothriechis bicolor" + }, + { + "name": "Hagen's pitviper", + "scientific": "Trimeresurus hageni" + }, + { + "name": "Hairy bush viper", + "scientific": "Atheris hispida" + }, + { + "name": "Himehabu", + "scientific": "Ovophis okinavensis" + }, + { + "name": "Hogg Island boa", + "scientific": "Boa constrictor imperator" + }, + { + "name": "Honduran palm viper", + "scientific": "Bothriechis marchi" + }, + { + "name": "Horned desert viper", + "scientific": "Cerastes cerastes" + }, + { + "name": "Horseshoe pitviper", + "scientific": "Trimeresurus strigatus" + }, + { + "name": "Hundred pacer", + "scientific": "Deinagkistrodon" + }, + { + "name": "Hutton's tree viper", + "scientific": "Tropidolaemus huttoni" + }, + { + "name": "Indian python", + "scientific": "Python molurus" + }, + { + "name": "Indian tree viper", + "scientific": "Trimeresurus gramineus" + }, + { + "name": "Indochinese spitting cobra", + "scientific": "Indochinese spitting cobra" + }, + { + "name": "Indonesian water python", + "scientific": "Liasis mackloti" + }, + { + "name": "Javan spitting cobra", + "scientific": "Javan spitting cobra" + }, + { + "name": "Jerdon's pitviper", + "scientific": "Trimeresurus jerdonii" + }, + { + "name": "Jumping viper", + "scientific": "Atropoides" + }, + { + "name": "Jungle carpet python", + "scientific": "Morelia spilota cheynei" + }, + { + "name": "Kanburian pit viper", + "scientific": "Trimeresurus kanburiensis" + }, + { + "name": "Kaulback's lance-headed pitviper", + "scientific": "Trimeresurus kaulbacki" + }, + { + "name": "Kaznakov's viper", + "scientific": "Vipera kaznakovi" + }, + { + "name": "Kham Plateau pitviper", + "scientific": "Protobothrops xiangchengensis" + }, + { + "name": "Lachesis (genus)", + "scientific": "Lachesis (genus)" + }, + { + "name": "Large-eyed pitviper", + "scientific": "Trimeresurus macrops" + }, + { + "name": "Large-scaled tree viper", + "scientific": "Trimeresurus macrolepis" + }, + { + "name": "Leaf-nosed viper", + "scientific": "Eristicophis" + }, + { + "name": "Leaf viper", + "scientific": "Atheris squamigera" + }, + { + "name": "Levant viper", + "scientific": "Macrovipera lebetina" + }, + { + "name": "Long-nosed viper", + "scientific": "Vipera ammodytes" + }, + { + "name": "Macklot's python", + "scientific": "Liasis mackloti" + }, + { + "name": "Madagascar tree boa", + "scientific": "Sanzinia" + }, + { + "name": "Malabar rock pitviper", + "scientific": "Trimeresurus malabaricus" + }, + { + "name": "Malcolm's tree viper", + "scientific": "Trimeresurus sumatranus malcolmi" + }, + { + "name": "Mandalay cobra", + "scientific": "Mandalay spitting cobra" + }, + { + "name": "Mangrove pit viper", + "scientific": "Trimeresurus purpureomaculatus" + }, + { + "name": "Mangshan pitviper", + "scientific": "Trimeresurus mangshanensis" + }, + { + "name": "McMahon's viper", + "scientific": "Eristicophis" + }, + { + "name": "Mexican palm-pitviper", + "scientific": "Bothriechis rowleyi" + }, + { + "name": "Monocled cobra", + "scientific": "Monocled cobra" + }, + { + "name": "Motuo bamboo pitviper", + "scientific": "Trimeresurus medoensis" + }, + { + "name": "Mozambique spitting cobra", + "scientific": "Mozambique spitting cobra" + }, + { + "name": "Namaqua dwarf adder", + "scientific": "Bitis schneideri" + }, + { + "name": "Namib dwarf sand adder", + "scientific": "Bitis peringueyi" + }, + { + "name": "New Guinea carpet python", + "scientific": "Morelia spilota variegata" + }, + { + "name": "Nicobar bamboo pitviper", + "scientific": "Trimeresurus labialis" + }, + { + "name": "Nitsche's bush viper", + "scientific": "Atheris nitschei" + }, + { + "name": "Nitsche's tree viper", + "scientific": "Atheris nitschei" + }, + { + "name": "Northwestern carpet python", + "scientific": "Morelia spilota variegata" + }, + { + "name": "Nubian spitting cobra", + "scientific": "Nubian spitting cobra" + }, + { + "name": "Oenpelli python", + "scientific": "Oenpelli python" + }, + { + "name": "Olive python", + "scientific": "Liasis olivaceus" + }, + { + "name": "Pallas' viper", + "scientific": "Gloydius halys" + }, + { + "name": "Palm viper", + "scientific": "Bothriechis lateralis" + }, + { + "name": "Papuan python", + "scientific": "Apodora" + }, + { + "name": "Peringuey's adder", + "scientific": "Bitis peringueyi" + }, + { + "name": "Philippine cobra", + "scientific": "Philippine cobra" + }, + { + "name": "Philippine pitviper", + "scientific": "Trimeresurus flavomaculatus" + }, + { + "name": "Pope's tree viper", + "scientific": "Trimeresurus popeorum" + }, + { + "name": "Portuguese viper", + "scientific": "Vipera seoanei" + }, + { + "name": "Puerto Rican boa", + "scientific": "Puerto Rican boa" + }, + { + "name": "Rainbow boa", + "scientific": "Rainbow boa" + }, + { + "name": "Red spitting cobra", + "scientific": "Red spitting cobra" + }, + { + "name": "Rhinoceros viper", + "scientific": "Bitis nasicornis" + }, + { + "name": "Rhombic night adder", + "scientific": "Causus maculatus" + }, + { + "name": "Rinkhals", + "scientific": "Rinkhals" + }, + { + "name": "Rinkhals cobra", + "scientific": "Rinkhals" + }, + { + "name": "River jack", + "scientific": "Bitis nasicornis" + }, + { + "name": "Rough-scaled bush viper", + "scientific": "Atheris hispida" + }, + { + "name": "Rough-scaled python", + "scientific": "Rough-scaled python" + }, + { + "name": "Rough-scaled tree viper", + "scientific": "Atheris hispida" + }, + { + "name": "Royal python", + "scientific": "Ball python" + }, + { + "name": "Rungwe tree viper", + "scientific": "Atheris nitschei rungweensis" + }, + { + "name": "Sakishima habu", + "scientific": "Trimeresurus elegans" + }, + { + "name": "Savu python", + "scientific": "Liasis mackloti savuensis" + }, + { + "name": "Schlegel's viper", + "scientific": "Bothriechis schlegelii" + }, + { + "name": "Schultze's pitviper", + "scientific": "Trimeresurus schultzei" + }, + { + "name": "Sedge viper", + "scientific": "Atheris nitschei" + }, + { + "name": "Sharp-nosed viper", + "scientific": "Deinagkistrodon" + }, + { + "name": "Siamese palm viper", + "scientific": "Trimeresurus puniceus" + }, + { + "name": "Side-striped palm-pitviper", + "scientific": "Bothriechis lateralis" + }, + { + "name": "Snorkel viper", + "scientific": "Deinagkistrodon" + }, + { + "name": "Snouted cobra", + "scientific": "Snouted cobra" + }, + { + "name": "Sonoran sidewinder", + "scientific": "Crotalus cerastes cercobombus" + }, + { + "name": "Southern Indonesian spitting cobra", + "scientific": "Javan spitting cobra" + }, + { + "name": "Southern Philippine cobra", + "scientific": "Samar cobra" + }, + { + "name": "Spiny bush viper", + "scientific": "Atheris hispida" + }, + { + "name": "Spitting cobra", + "scientific": "Spitting cobra" + }, + { + "name": "Spotted python", + "scientific": "Spotted python" + }, + { + "name": "Sri Lankan pit viper", + "scientific": "Trimeresurus trigonocephalus" + }, + { + "name": "Stejneger's bamboo pitviper", + "scientific": "Trimeresurus stejnegeri" + }, + { + "name": "Storm water cobra", + "scientific": "Naja annulata" + }, + { + "name": "Sumatran tree viper", + "scientific": "Trimeresurus sumatranus" + }, + { + "name": "Temple viper", + "scientific": "Tropidolaemus wagleri" + }, + { + "name": "Tibetan bamboo pitviper", + "scientific": "Trimeresurus tibetanus" + }, + { + "name": "Tiger pit viper", + "scientific": "Trimeresurus kanburiensis" + }, + { + "name": "Timor python", + "scientific": "Python timoriensis" + }, + { + "name": "Tokara habu", + "scientific": "Trimeresurus tokarensis" + }, + { + "name": "Tree boa", + "scientific": "Emerald tree boa" + }, + { + "name": "Undulated pit viper", + "scientific": "Ophryacus undulatus" + }, + { + "name": "Ursini's viper", + "scientific": "Vipera ursinii" + }, + { + "name": "Wagler's pit viper", + "scientific": "Tropidolaemus wagleri" + }, + { + "name": "West African brown spitting cobra", + "scientific": "Mozambique spitting cobra" + }, + { + "name": "White-lipped tree viper", + "scientific": "Trimeresurus albolabris" + }, + { + "name": "Wirot's pit viper", + "scientific": "Trimeresurus puniceus" + }, + { + "name": "Yellow-lined palm viper", + "scientific": "Bothriechis lateralis" + }, + { + "name": "Zebra spitting cobra", + "scientific": "Naja nigricincta" + }, + { + "name": "Yarara", + "scientific": "Bothrops jararaca" + }, + { + "name": "Wetar Island python", + "scientific": "Liasis macklot" + }, + { + "name": "Urutus", + "scientific": "Bothrops alternatus" + }, + { + "name": "Titanboa", + "scientific": "Titanoboa" + } +] diff --git a/bot/resources/fun/snakes/snake_quiz.json b/bot/resources/fun/snakes/snake_quiz.json new file mode 100644 index 00000000..8c426b22 --- /dev/null +++ b/bot/resources/fun/snakes/snake_quiz.json @@ -0,0 +1,200 @@ +[ + { + "id": 0, + "question": "How long have snakes been roaming the Earth for?", + "options": { + "a": "3 million years", + "b": "30 million years", + "c": "130 million years", + "d": "200 million years" + }, + "answerkey": "c" + }, + { + "id": 1, + "question": "What characteristics do all snakes share?", + "options": { + "a": "They are carnivoes", + "b": "They are all programming languages", + "c": "They're all cold-blooded", + "d": "They are both carnivores and cold-blooded" + }, + "answerkey": "c" + }, + { + "id": 2, + "question": "How do snakes hear?", + "options": { + "a": "With small ears", + "b": "Through their skin", + "c": "Through their tail", + "d": "They don't use their ears at all" + }, + "answerkey": "b" + }, + { + "id": 3, + "question": "What can't snakes see?", + "options": { + "a": "Colour", + "b": "Light", + "c": "Both of the above", + "d": "Other snakes" + }, + "answerkey": "a" + }, + { + "id": 4, + "question": "What unique vision ability do boas and pythons possess?", + "options": { + "a": "Night vision", + "b": "Infrared vision", + "c": "See through walls", + "d": "They don't have vision" + }, + "answerkey": "b" + }, + { + "id": 5, + "question": "How does a snake smell?", + "options": { + "a": "Quite pleasant", + "b": "Through its nose", + "c": "Through its tongues", + "d": "Both through its nose and its tongues" + }, + "answerkey": "d" + }, + { + "id": 6, + "question": "Where are Jacobson's organs located in snakes?", + "options": { + "a": "Mouth", + "b": "Tail", + "c": "Stomach", + "d": "Liver" + }, + "answerkey": "a" + }, + { + "id": 7, + "question": "Snakes have very similar internal organs compared to humans. Snakes, however; lack the following:", + "options": { + "a": "A diaphragm", + "b": "Intestines", + "c": "Lungs", + "d": "Kidney" + }, + "answerkey": "a" + }, + { + "id": 8, + "question": "Snakes have different shaped lungs than humans. What do snakes have?", + "options": { + "a": "An elongated right lung", + "b": "A small left lung", + "c": "Both of the above", + "d": "None of the above" + }, + "answerkey": "c" + }, + { + "id": 9, + "question": "What's true about two-headed snakes?", + "options": { + "a": "They're a myth!", + "b": "They rarely survive in the wild", + "c": "They're very dangerous", + "d": "They can kiss each other" + }, + "answerkey": "b" + }, + { + "id": 10, + "question": "What substance covers a snake's skin?", + "options": { + "a": "Calcium", + "b": "Keratin", + "c": "Copper", + "d": "Iron" + }, + "answerkey": "b" + }, + { + "id": 11, + "question": "What snake doesn't have to have a mate to lay eggs?", + "options": { + "a": "Copperhead", + "b": "Cornsnake", + "c": "Kingsnake", + "d": "Flower pot snake" + }, + "answerkey": "d" + }, + { + "id": 12, + "question": "What snake is the longest?", + "options": { + "a": "Green anaconda", + "b": "Reticulated python", + "c": "King cobra", + "d": "Kingsnake" + }, + "answerkey": "b" + }, + { + "id": 13, + "question": "Though invasive species can now be found in the Everglades, in which three continents are pythons (members of the family Pythonidae) found in the wild?", + "options": { + "a": "Africa, Asia and Australia", + "b": "Africa, Australia and Europe", + "c": "Africa, Australia and South America", + "d": "Africa, Asia and South America" + }, + "answerkey": "a" + }, + { + "id": 14, + "question": "Pythons are held as some of the most dangerous snakes on earth, but are often confused with anacondas. Which of these is *not* a difference between pythons and anacondas?", + "options": { + "a": "Pythons suffocate their prey, anacondas crush them", + "b": "Pythons lay eggs, anacondas give birth to live young", + "c": "Pythons grow longer, anacondas grow heavier", + "d": "Pythons generally spend less time in water than anacondas do" + }, + "answerkey": "a" + }, + { + "id": 15, + "question": "Pythons are unable to chew their food, and so swallow prey whole. Which of these methods is most commonly demonstrated to help a python to swallow large prey?", + "options": { + "a": "The python's stomach pressure is reduced, so prey is sucked in", + "b": "An extra set of upper teeth 'walk' along the prey", + "c": "The python holds its head up, so prey falls into its stomach", + "d": "Prey is pushed against a barrier and is forced down the python's throat" + }, + "answerkey": "b" + }, + { + "id": 16, + "question": "Pythons, like many large constrictors, possess vestigial hind limbs. Whilst these 'spurs' serve no purpose in locomotion, how are they put to use by some male pythons? ", + "options": { + "a": "To store sperm", + "b": "To release pheromones", + "c": "To grip females during mating", + "d": "To fight off rival males" + }, + "answerkey": "c" + }, + { + "id": 17, + "question": "Pythons tend to travel by the rectilinear method (in straight lines) when on land, as opposed to the concertina method (s-shaped movement). Why do large pythons tend not to use the concertina method? ", + "options": { + "a": "Their spine is too inflexible", + "b": "They move too slowly", + "c": "The scales on their backs are too rigid", + "d": "They are too heavy" + }, + "answerkey": "d" + } +] diff --git a/bot/resources/fun/snakes/snakes_and_ladders/banner.jpg b/bot/resources/fun/snakes/snakes_and_ladders/banner.jpg new file mode 100644 index 00000000..69eaaf12 Binary files /dev/null and b/bot/resources/fun/snakes/snakes_and_ladders/banner.jpg differ diff --git a/bot/resources/fun/snakes/snakes_and_ladders/board.jpg b/bot/resources/fun/snakes/snakes_and_ladders/board.jpg new file mode 100644 index 00000000..20032e39 Binary files /dev/null and b/bot/resources/fun/snakes/snakes_and_ladders/board.jpg differ diff --git a/bot/resources/fun/snakes/special_snakes.json b/bot/resources/fun/snakes/special_snakes.json new file mode 100644 index 00000000..46214f66 --- /dev/null +++ b/bot/resources/fun/snakes/special_snakes.json @@ -0,0 +1,16 @@ +[ + { + "name": "Bob Ross", + "info": "Robert Norman Ross (October 29, 1942 – July 4, 1995) was an American painter, art instructor, and television host. He was the creator and host of The Joy of Painting, an instructional television program that aired from 1983 to 1994 on PBS in the United States, and also aired in Canada, Latin America, and Europe.", + "image_list": [ + "https://d3atagt0rnqk7k.cloudfront.net/wp-content/uploads/2016/09/23115633/bob-ross-1-1280x800.jpg" + ] + }, + { + "name": "Mystery Snake", + "info": "The Mystery Snake is rumored to be a thin, serpentine creature that hides in spaghetti dinners. It has yellow, pasta-like scales with a completely smooth texture, and is quite glossy. ", + "image_list": [ + "https://img.thrfun.com/img/080/349/spaghetti_dinner_l1.jpg" + ] + } +] diff --git a/bot/resources/snakes/snake_cards/backs/card_back1.jpg b/bot/resources/snakes/snake_cards/backs/card_back1.jpg deleted file mode 100644 index 22959fa7..00000000 Binary files a/bot/resources/snakes/snake_cards/backs/card_back1.jpg and /dev/null differ diff --git a/bot/resources/snakes/snake_cards/backs/card_back2.jpg b/bot/resources/snakes/snake_cards/backs/card_back2.jpg deleted file mode 100644 index d56edc32..00000000 Binary files a/bot/resources/snakes/snake_cards/backs/card_back2.jpg and /dev/null differ diff --git a/bot/resources/snakes/snake_cards/card_bottom.png b/bot/resources/snakes/snake_cards/card_bottom.png deleted file mode 100644 index 8b2b91c5..00000000 Binary files a/bot/resources/snakes/snake_cards/card_bottom.png and /dev/null differ diff --git a/bot/resources/snakes/snake_cards/card_frame.png b/bot/resources/snakes/snake_cards/card_frame.png deleted file mode 100644 index 149a0a5f..00000000 Binary files a/bot/resources/snakes/snake_cards/card_frame.png and /dev/null differ diff --git a/bot/resources/snakes/snake_cards/card_top.png b/bot/resources/snakes/snake_cards/card_top.png deleted file mode 100644 index e329c873..00000000 Binary files a/bot/resources/snakes/snake_cards/card_top.png and /dev/null differ diff --git a/bot/resources/snakes/snake_cards/expressway.ttf b/bot/resources/snakes/snake_cards/expressway.ttf deleted file mode 100644 index 39e15794..00000000 Binary files a/bot/resources/snakes/snake_cards/expressway.ttf and /dev/null differ diff --git a/bot/resources/snakes/snake_facts.json b/bot/resources/snakes/snake_facts.json deleted file mode 100644 index ca9ba769..00000000 --- a/bot/resources/snakes/snake_facts.json +++ /dev/null @@ -1,233 +0,0 @@ -[ - { - "fact": "The decapitated head of a dead snake can still bite, even hours after death. These types of bites usually contain huge amounts of venom." - }, - { - "fact": "What is considered the most 'dangerous' snake depends on both a specific country’s health care and the availability of antivenom following a bite. Based on these criteria, the most dangerous snake in the world is the saw-scaled viper, which bites and kills more people each year than any other snake." - }, - { - "fact": "Snakes live everywhere on Earth except Ireland, Iceland, New Zealand, and the North and South Poles." - }, - { - "fact": "Of the approximately 725 species of venomous snakes worldwide, 250 can kill a human with one bite." - }, - { - "fact": "Snakes evolved from a four-legged reptilian ancestor—most likely a small, burrowing, land-bound lizard—about 100 million years ago. Some snakes, such as pythons and boas, still have traces of back legs." - }, - { - "fact": "The fear of snakes (ophiophobia or herpetophobia) is one of the most common phobias worldwide. Approximately 1/3 of all adult humans areophidiophobic , which suggests that humans have an innate, evolutionary fear of snakes." - }, - { - "fact": "The top 5 most venomous snakes in the world are the inland taipan, the eastern brown snake, the coastal taipan, the tiger snake, and the black tiger snake." - }, - { - "fact": "The warmer a snake’s body, the more quickly it can digest its prey. Typically, it takes 3–5 days for a snake to digest its meal. For very large snakes, such as the anaconda, digestion can take weeks." - }, - { - "fact": "Some animals, such as the Mongoose, are immune to snake venom." - }, - { - "fact": "To avoid predators, some snakes can poop whenever they want. They make themselves so dirty and smelly that predators will run away." - }, - { - "fact": "The heaviest snake in the world is the anaconda. It weighs over 595 pounds (270 kg) and can grow to over 30 feet (9m) long. It has been known to eat caimans, capybaras, and jaguars." - }, - { - "fact": "The Brahminy Blind Snake, or flowerpot snake, is the only snake species made up of solely females and, as such, does not need a mate to reproduce. It is also the most widespread terrestrial snake in the world." - }, - { - "fact": "If a person suddenly turned into a snake, they would be about 4 times longer than they are now and only a few inches thick. While humans have 24 ribs, some snakes can have more than 400." - }, - { - "fact": "The most advanced snake species in the world is believed to be the black mamba. It has the most highly evolved venom delivery system of any snake on Earth. It can strike up to 12 times in a row, though just one bite is enough to kill a grown man.o" - }, - { - "fact": "The inland taipan is the world’s most toxic snake, meaning it has both the most toxic venom and it injects the most venom when it bites. Its venom sacs hold enough poison to kill up to 80 people." - }, - { - "fact": "The death adder has the fastest strike of any snake in the world. It can attack, inject venom, and go back to striking position in under 0.15 seconds." - }, - { - "fact": "While snakes do not have external ears or eardrums, their skin, muscles, and bones carry sound vibrations to their inner ears." - }, - { - "fact": "Some snakes have been known to explode after eating a large meal. For example, a 13-foot python blew up after it tried to eat a 6-foot alligator. The python was found with the alligator’s tail protruding from its midsection. Its head was missing." - }, - { - "fact": "The word 'snake' is from the Proto-Indo-European root *sneg -, meaning 'to crawl, creeping thing.' The word 'serpent' is from the Proto-Indo-European root *serp -, meaning 'to crawl, creep.'" - }, - { - "fact": "Rattlesnake rattles are made of rings of keratin, which is the same material as human hair and fingernails. A rattler will add a new ring each time it sheds its skin." - }, - { - "fact": "Some snakes have over 200 teeth. The teeth aren’t used for chewing but they point backward to prevent prey from escaping the snake’s throat." - }, - { - "fact": "There are about 500 genera and 3,000 different species of snakes. All of them are predators." - }, - { - "fact": "Naturalist Paul Rosolie attempted to be the first person to survive being swallowed by an anaconda in 2014. Though he was wearing a specially designed carbon fiber suit equipped with a breathing system, cameras, and a communication system, he ultimately called off his stunt when he felt like the anaconda was breaking his arm as it tightened its grip around his body." - }, - { - "fact": "There are five recognized species of flying snakes. Growing up to 4 feet, some types can glide up to 330 feet through the air." - }, - { - "fact": "Scales cover every inch of a snake’s body, even its eyes. Scales are thick, tough pieces of skin made from keratin, which is the same material human nails and hair are made from." - }, - { - "fact": "The most common snake in North America is the garter (gardener) snake. This snake is also Massachusetts’s state reptile. While previously thought to be nonvenomous, garter snakes do, in fact, produce a mild neurotoxic venom that is harmless to humans." - }, - { - "fact": "Snakes do not lap up water like mammals do. Instead, they dunk their snouts underwater and use their throats to pump water into their stomachs." - }, - { - "fact": "A snake’s fangs usually last about 6–10 weeks. When a fang wears out, a new one grows in its place." - }, - { - "fact": "Because the end of a snake’s tongue is forked, the two tips taste different amounts of chemicals. Essentially, a snake 'smells in stereo' and can even tell which direction a smell is coming from. It identifies scents on its tongue using pits in the roof of its mouth called the Jacobson’s organ." - }, - { - "fact": "The amount of food a snake eats determines how many offspring it will have. The Arafura file snake eats the least and lays just one egg every decade." - }, - { - "fact": "While smaller snakes, such a tree- or- ground-dwelling snakes, use their tongues to follow the scent trails of prey (such as spiders, birds, and other snakes). Larger snakes, such as boas, have heat-sensing organs called labial (lip) pits in their snouts." - }, - { - "fact": "Snakes typically need to eat only 6–30 meals each year to be healthy." - }, - { - "fact": "Snakes like to lie on roads and rocky areas because stones and rocks absorb heat from the sun, which warms them. Basking on these surfaces warms a snake quickly so it can move. If the temperature reaches below 50° Fahrenheit, a snake’s body does not work properly." - }, - { - "fact": "The Mozambique spitting cobra can spit venom over 8 feet away. It can spit from any position, including lying on the ground or raised up. It prefers to aim for its victim’s eyes." - }, - { - "fact": "Snakes cannot chew, so they must swallow their food whole. They are able to stretch their mouths very wide because they have a very flexible lower jaw. Snakes can eat other animals that are 75%–100% bigger than their own bodies." - }, - { - "fact": "To keep from choking on large prey, a snake will push the end of its trachea, or windpipe, out of its mouth, similar to the way a snorkel works." - }, - { - "fact": "The Gaboon viper has the longest fangs of any snake, reaching about 2 inches (5 cm) long." - }, - { - "fact": "Anacondas can hold their breath for up to 10 minutes under water. Additionally, similar to crocodiles, anacondas have eyes and nostrils that can poke above the water’s surface to increase their stealth and hunting prowess." - }, - { - "fact": "The longest snake ever recorded is the reticulated python. It can reach over 33 feet long, which is big enough to swallow a pig, a deer, or even a person." - }, - { - "fact": "Sea snakes with their paddle-shaped tails can dive over 300 feet into the ocean." - }, - { - "fact": "If a snake is threatened soon after a meal, it will often regurgitate its food so it can quickly escape the perceived threat. A snake’s digestive system can dissolve everything but a prey’s hair, feathers, and claws." - }, - { - "fact": "Snakes do not have eyelids; rather, a single transparent scale called a brille protects their eyes. Most snakes see very well, especially if the object is moving." - }, - { - "fact": "The world’s longest venomous snake is the king cobra from Asia. It can grow up to 18 feet, rear almost as high as a person, growl loudly, and inject enough venom to kill an elephant." - }, - { - "fact": "The king cobra is thought to be one of the most intelligent of all snakes. Additionally, unlike most snakes, who do not care for their young, king cobras are careful parents who defend and protect their eggs from enemies." - }, - { - "fact": "Not all snakes have fangs—only those that kill their prey with venom have them. When their fangs are not in use, they fold them back into the roof of the mouth (except for the coral snake, whose fangs do not fold back)." - }, - { - "fact": "Some venomous snakes have died after biting and poisoning themselves by mistake." - }, - { - "fact": "Elephant trunk snakes are almost completely aquatic. They cannot slither because they lack the broad scales in the belly that help other snakes move on land. Rather, elephant trunk snakes have large knobby scales to hold onto slippery fish and constrict them underwater." - }, - { - "fact": "The shortest known snake is the thread snake. It is about 4 inches long and lives on the island of Barbados in the Caribbean. It is said to be as 'thin as spaghetti' and it feeds primarily on termites and larvae." - }, - { - "fact": "In 2009, a farm worker in East Africa survived an epic 3-hour battle with a 12-foot python after accidentally stepping on the large snake. It coiled around the man and carried him into a tree. The man wrapped his shirt over the snake’s mouth to prevent it from swallowing him, and he was finally rescued by police after calling for help on his cell phone." - }, - { - "fact": "The venom from a Brazilian pit viper is used in a drug to treat high blood pressure." - }, - { - "fact": "The word 'cobra' means 'hooded.' Some cobras have large spots on the back of their hood that look like eyes to make them appear intimating even from behind." - }, - { - "fact": "Some desert snakes, such as the African rock python, sleep during the hottest parts of the desert summer. This summer sleep is similar to hibernation and is called “aestivation.”" - }, - { - "fact": "The black mamba is the world’s fastest snake and the world’s second-longest venomous snake in the world, after the king cobra. Found in East Africa, it can reach speeds of up to 12 mph (19kph). It’s named not from the color of its scales, which is olive green, but from the inside of its mouth, which is inky black. Its venom is highly toxic, and without anti-venom, death in humans usually occurs within 7–15 hours." - }, - { - "fact": "Although a snake’s growth rate slows as it gets older, a snake never stops growing." - }, - { - "fact": "While a snake cannot hear the music of a snake charmer, the snake responds to the vibrations of the charmer’s tapping foot or to the movement of the flute." - }, - { - "fact": "Most snakes are not harmful to humans and they help balance the ecosystem by keeping the population of rats, mice, and birds under control." - }, - { - "fact": "The largest snake fossil ever found is the Titanoboa. It lived over 60 million years ago and reached over 50 feet (15 meters) long. It weighed more than 20 people and ate crocodiles and giant tortoises." - }, - { - "fact": "Two-headed snakes are similar to conjoined twins: an embryo begins to split to create identical twins, but the process does not finish. Such snakes rarely survive in the wild because the two heads have duplicate senses, they fight over food, and one head may try to eat the other head." - }, - { - "fact": "Snakes can be grouped into two sections: primitive snakes and true (typical) snakes. Primitive snakes—such as blind snakes, worm snakes, and thread snakes—represent the earliest forms of snakes. True snakes, such as rat snakes and king snakes, are more evolved and more active." - }, - { - "fact": "The oldest written record that describes snakes is in the Brooklyn Papyrus, which is a medical papyrus dating from ancient Egypt (450 B.C.)." - }, - { - "fact": "Approximately 70% of snakes lay eggs. Those that lay eggs are called oviparous. The other 30% of snakes live in colder climates and give birth to live young because it is too cold for eggs outside the body to develop and hatch." - }, - { - "fact": "Most snakes have an elongated right lung, many have a smaller left lung, and a few even have a third lung. They do not have a sense of taste, and most of their organs are organized linearly." - }, - { - "fact": "The most rare and endangered snake is the St. Lucia racer. There are only 18 to 100 of these snakes left." - }, - { - "fact": "Snakes kill over 40,000 people a year—though, with unreported incidents, the total may be over 100,000. About half of these deaths are in India." - }, - { - "fact": "In some cultures, eating snakes is considered a delicacy. For example, snake soup has been a popular Cantonese delicacy for over 2,000 years." - }, - { - "fact": "In some Asian countries, it is believed that drinking the blood of snakes, particularly the cobra, will increase sexual virility. The blood is usually drained from a live snake and then mixed with liquor." - }, - { - "fact": "In the United States, fewer than 1 in 37,500 people are bitten by venomous snakes each year (7,000–8,000 bites per year), and only 1 in 50 million people will die from snake bite (5–6 fatalities per year). In the U.S., a person is 9 times more likely to die from being struck by lightening than to die from a venomous snakebite." - }, - { - "fact": "Some members of the U.S. Army Special Forces are taught to kill and eat snakes during their survival training, which has earned them the nickname 'Snake Eaters.'" - }, - { - "fact": "One of the great feats of the legendary Greek hero Perseus was to kill Medusa, a female monster whose hair consisted of writhing, venomous snakes." - }, - { - "fact": "The symbol of the snake is one of the most widespread and oldest cultural symbols in history. Snakes often represent the duality of good and evil and of life and death." - }, - { - "fact": "Because snakes shed their skin, they are often symbols of rebirth, transformation, and healing. For example, Asclepius, the god of medicine, carries a staff encircled by a snake." - }, - { - "fact": "The snake has held various meanings throughout history. For example, The Egyptians viewed the snake as representing royalty and deity. In the Jewish rabbinical tradition and in Hinduism, it represents sexual passion and desire. And the Romans interpreted the snake as a symbol of eternal love." - }, - { - "fact": "Anacondas mate in a huge 'breeding ball.' The ball consists of 1 female and nearly 12 males. They stay in a 'mating ball' for up to a month." - }, - { - "fact": "Depending on the species, snakes can live from 4 to over 25 years." - }, - { - "fact": "Snakes that are poisonous have pupils that are shaped like a diamond. Nonpoisonous snakes have round pupils." - }, - { - "fact": "Endangered snakes include the San Francisco garter snake, eastern indigo snake, the king cobra, and Dumeril’s boa." - }, - { - "fact": "A mysterious, new 'mad snake disease' causes captive pythons and boas to tie themselves in knots. Other symptoms include 'stargazing,' which is when snakes stare upwards for long periods of time. Snake experts believe a rodent virus causes the fatal disease." - } -] diff --git a/bot/resources/snakes/snake_idioms.json b/bot/resources/snakes/snake_idioms.json deleted file mode 100644 index ecbeb6ff..00000000 --- a/bot/resources/snakes/snake_idioms.json +++ /dev/null @@ -1,275 +0,0 @@ -[ - { - "idiom": "snek it up" - }, - { - "idiom": "get ur snek on" - }, - { - "idiom": "snek ur heart out" - }, - { - "idiom": "snek 4 ever" - }, - { - "idiom": "i luve snek" - }, - { - "idiom": "snek bff" - }, - { - "idiom": "boyfriend snek" - }, - { - "idiom": "dont snek ur homies" - }, - { - "idiom": "garden snek" - }, - { - "idiom": "snektie" - }, - { - "idiom": "snek keks" - }, - { - "idiom": "birthday snek!" - }, - { - "idiom": "snek tonight?" - }, - { - "idiom": "snek hott lips" - }, - { - "idiom": "snek u latr" - }, - { - "idiom": "netflx and snek" - }, - { - "idiom": "holy snek prey4u" - }, - { - "idiom": "ghowst snek hauntt u" - }, - { - "idiom": "ipekek snek syrop" - }, - { - "idiom": "2 snek 2 furius" - }, - { - "idiom": "the shawsnek redumpton" - }, - { - "idiom": "snekler's list" - }, - { - "idiom": "snekablanca" - }, - { - "idiom": "romeo n snekulet" - }, - { - "idiom": "citizn snek" - }, - { - "idiom": "gon wit the snek" - }, - { - "idiom": "dont step on snek" - }, - { - "idiom": "the wizrd uf snek" - }, - { - "idiom": "forrest snek" - }, - { - "idiom": "snek of musik" - }, - { - "idiom": "west snek story" - }, - { - "idiom": "snek wars eposide XI" - }, - { - "idiom": "2001: a snek odyssuuy" - }, - { - "idiom": "E.T. the snekstra terrastriul" - }, - { - "idiom": "snekkin' inth rain" - }, - { - "idiom": "dr sneklove" - }, - { - "idiom": "snekley kubrik" - }, - { - "idiom": "willium snekspeare" - }, - { - "idiom": "snek on tutanic" - }, - { - "idiom": "a snekwork orunge" - }, - { - "idiom": "the snek the bad n the ogly" - }, - { - "idiom": "the sneksorcist" - }, - { - "idiom": "gudd snek huntin" - }, - { - "idiom": "leonurdo disnekrio" - }, - { - "idiom": "denzal snekington" - }, - { - "idiom": "snekuel l jocksons" - }, - { - "idiom": "kevn snek" - }, - { - "idiom": "snekthony hopkuns" - }, - { - "idiom": "hugh snekman" - }, - { - "idiom": "snek but it glow in durk" - }, - { - "idiom": "snek but u cn ride it" - }, - { - "idiom": "snek but slep in ur bed" - }, - { - "idiom": "snek but mad frum plastk" - }, - { - "idiom": "snek but bulong 2 ur frnd" - }, - { - "idiom": "sneks on plene" - }, - { - "idiom": "baby snek" - }, - { - "idiom": "trouser snek" - }, - { - "idiom": "momo snek" - }, - { - "idiom": "fast snek" - }, - { - "idiom": "super slow snek" - }, - { - "idiom": "old snek" - }, - { - "idiom": "slimy snek" - }, - { - "idiom": "snek attekk" - }, - { - "idiom": "snek get wrekk" - }, - { - "idiom": "snek you long time" - }, - { - "idiom": "carpenter snek" - }, - { - "idiom": "drain snek" - }, - { - "idiom": "eat ur face snek" - }, - { - "idiom": "kawaii snek" - }, - { - "idiom": "dis snek is soft" - }, - { - "idiom": "snek is 4 yers uld" - }, - { - "idiom": "pls feed snek, is hingry" - }, - { - "idiom": "snek? snek? sneeeeek!!" - }, - { - "idiom": "solid snek" - }, - { - "idiom": "big bos snek" - }, - { - "idiom": "snek republic" - }, - { - "idiom": "snekoslovakia" - }, - { - "idiom": "snek please!" - }, - { - "idiom": "i brok my snek :(" - }, - { - "idiom": "star snek the nxt generatin" - }, - { - "idiom": "azsnek tempul" - }, - { - "idiom": "discosnek" - }, - { - "idiom": "bottlsnek" - }, - { - "idiom": "turtlsnek" - }, - { - "idiom": "cashiers snek" - }, - { - "idiom": "mega snek!!" - }, - { - "idiom": "one tim i saw snek neked" - }, - { - "idiom": "snek cnt clim trees" - }, - { - "idiom": "snek in muth is jus tongue" - }, - { - "idiom": "juan snek" - }, - { - "idiom": "photosnek" - } -] diff --git a/bot/resources/snakes/snake_names.json b/bot/resources/snakes/snake_names.json deleted file mode 100644 index 25832550..00000000 --- a/bot/resources/snakes/snake_names.json +++ /dev/null @@ -1,2170 +0,0 @@ -[ - { - "name": "Acanthophis", - "scientific": "Acanthophis" - }, - { - "name": "Aesculapian snake", - "scientific": "Aesculapian snake" - }, - { - "name": "African beaked snake", - "scientific": "Rufous beaked snake" - }, - { - "name": "African puff adder", - "scientific": "Bitis arietans" - }, - { - "name": "African rock python", - "scientific": "African rock python" - }, - { - "name": "African twig snake", - "scientific": "Twig snake" - }, - { - "name": "Agkistrodon piscivorus", - "scientific": "Agkistrodon piscivorus" - }, - { - "name": "Ahaetulla", - "scientific": "Ahaetulla" - }, - { - "name": "Amazonian palm viper", - "scientific": "Bothriopsis bilineata" - }, - { - "name": "American copperhead", - "scientific": "Agkistrodon contortrix" - }, - { - "name": "Amethystine python", - "scientific": "Amethystine python" - }, - { - "name": "Anaconda", - "scientific": "Anaconda" - }, - { - "name": "Andaman cat snake", - "scientific": "Boiga andamanensis" - }, - { - "name": "Andrea's keelback", - "scientific": "Amphiesma andreae" - }, - { - "name": "Annulated sea snake", - "scientific": "Hydrophis cyanocinctus" - }, - { - "name": "Arafura file snake", - "scientific": "Acrochordus arafurae" - }, - { - "name": "Arizona black rattlesnake", - "scientific": "Crotalus oreganus cerberus" - }, - { - "name": "Arizona coral snake", - "scientific": "Coral snake" - }, - { - "name": "Aruba rattlesnake", - "scientific": "Crotalus durissus unicolor" - }, - { - "name": "Asian cobra", - "scientific": "Indian cobra" - }, - { - "name": "Asian keelback", - "scientific": "Amphiesma vibakari" - }, - { - "name": "Asp (reptile)", - "scientific": "Asp (reptile)" - }, - { - "name": "Assam keelback", - "scientific": "Amphiesma pealii" - }, - { - "name": "Australian copperhead", - "scientific": "Austrelaps" - }, - { - "name": "Australian scrub python", - "scientific": "Amethystine python" - }, - { - "name": "Baird's rat snake", - "scientific": "Pantherophis bairdi" - }, - { - "name": "Banded Flying Snake", - "scientific": "Banded flying snake" - }, - { - "name": "Banded cat-eyed snake", - "scientific": "Banded cat-eyed snake" - }, - { - "name": "Banded krait", - "scientific": "Banded krait" - }, - { - "name": "Barred wolf snake", - "scientific": "Lycodon striatus" - }, - { - "name": "Beaked sea snake", - "scientific": "Enhydrina schistosa" - }, - { - "name": "Beauty rat snake", - "scientific": "Beauty rat snake" - }, - { - "name": "Beddome's cat snake", - "scientific": "Boiga beddomei" - }, - { - "name": "Beddome's coral snake", - "scientific": "Beddome's coral snake" - }, - { - "name": "Bird snake", - "scientific": "Twig snake" - }, - { - "name": "Black-banded trinket snake", - "scientific": "Oreocryptophis porphyraceus" - }, - { - "name": "Black-headed snake", - "scientific": "Western black-headed snake" - }, - { - "name": "Black-necked cobra", - "scientific": "Black-necked spitting cobra" - }, - { - "name": "Black-necked spitting cobra", - "scientific": "Black-necked spitting cobra" - }, - { - "name": "Black-striped keelback", - "scientific": "Buff striped keelback" - }, - { - "name": "Black-tailed horned pit viper", - "scientific": "Mixcoatlus melanurus" - }, - { - "name": "Black headed python", - "scientific": "Black-headed python" - }, - { - "name": "Black krait", - "scientific": "Greater black krait" - }, - { - "name": "Black mamba", - "scientific": "Black mamba" - }, - { - "name": "Black rat snake", - "scientific": "Rat snake" - }, - { - "name": "Black tree cobra", - "scientific": "Cobra" - }, - { - "name": "Blind snake", - "scientific": "Scolecophidia" - }, - { - "name": "Blonde hognose snake", - "scientific": "Hognose" - }, - { - "name": "Blood python", - "scientific": "Python brongersmai" - }, - { - "name": "Blue krait", - "scientific": "Bungarus candidus" - }, - { - "name": "Blunt-headed tree snake", - "scientific": "Imantodes cenchoa" - }, - { - "name": "Boa constrictor", - "scientific": "Boa constrictor" - }, - { - "name": "Bocourt's water snake", - "scientific": "Subsessor" - }, - { - "name": "Boelen python", - "scientific": "Morelia boeleni" - }, - { - "name": "Boidae", - "scientific": "Boidae" - }, - { - "name": "Boiga", - "scientific": "Boiga" - }, - { - "name": "Boomslang", - "scientific": "Boomslang" - }, - { - "name": "Brahminy blind snake", - "scientific": "Indotyphlops braminus" - }, - { - "name": "Brazilian coral snake", - "scientific": "Coral snake" - }, - { - "name": "Brazilian smooth snake", - "scientific": "Hydrodynastes gigas" - }, - { - "name": "Brown snake (disambiguation)", - "scientific": "Brown snake" - }, - { - "name": "Brown tree snake", - "scientific": "Brown tree snake" - }, - { - "name": "Brown white-lipped python", - "scientific": "Leiopython" - }, - { - "name": "Buff striped keelback", - "scientific": "Buff striped keelback" - }, - { - "name": "Bull snake", - "scientific": "Bull snake" - }, - { - "name": "Burmese keelback", - "scientific": "Burmese keelback water snake" - }, - { - "name": "Burmese krait", - "scientific": "Burmese krait" - }, - { - "name": "Burmese python", - "scientific": "Burmese python" - }, - { - "name": "Burrowing viper", - "scientific": "Atractaspidinae" - }, - { - "name": "Buttermilk racer", - "scientific": "Coluber constrictor anthicus" - }, - { - "name": "California kingsnake", - "scientific": "California kingsnake" - }, - { - "name": "Cantor's pitviper", - "scientific": "Trimeresurus cantori" - }, - { - "name": "Cape cobra", - "scientific": "Cape cobra" - }, - { - "name": "Cape coral snake", - "scientific": "Aspidelaps lubricus" - }, - { - "name": "Cape gopher snake", - "scientific": "Cape gopher snake" - }, - { - "name": "Carpet viper", - "scientific": "Echis" - }, - { - "name": "Cat-eyed night snake", - "scientific": "Banded cat-eyed snake" - }, - { - "name": "Cat-eyed snake", - "scientific": "Banded cat-eyed snake" - }, - { - "name": "Cat snake", - "scientific": "Boiga" - }, - { - "name": "Central American lyre snake", - "scientific": "Trimorphodon biscutatus" - }, - { - "name": "Central ranges taipan", - "scientific": "Taipan" - }, - { - "name": "Chappell Island tiger snake", - "scientific": "Tiger snake" - }, - { - "name": "Checkered garter snake", - "scientific": "Checkered garter snake" - }, - { - "name": "Checkered keelback", - "scientific": "Checkered keelback" - }, - { - "name": "Children's python", - "scientific": "Children's python" - }, - { - "name": "Chinese cobra", - "scientific": "Chinese cobra" - }, - { - "name": "Coachwhip snake", - "scientific": "Masticophis flagellum" - }, - { - "name": "Coastal taipan", - "scientific": "Coastal taipan" - }, - { - "name": "Cobra", - "scientific": "Cobra" - }, - { - "name": "Collett's snake", - "scientific": "Collett's snake" - }, - { - "name": "Common adder", - "scientific": "Vipera berus" - }, - { - "name": "Common cobra", - "scientific": "Chinese cobra" - }, - { - "name": "Common garter snake", - "scientific": "Common garter snake" - }, - { - "name": "Common ground snake", - "scientific": "Western ground snake" - }, - { - "name": "Common keelback (disambiguation)", - "scientific": "Common keelback" - }, - { - "name": "Common tiger snake", - "scientific": "Tiger snake" - }, - { - "name": "Common worm snake", - "scientific": "Indotyphlops braminus" - }, - { - "name": "Congo snake", - "scientific": "Amphiuma" - }, - { - "name": "Congo water cobra", - "scientific": "Naja christyi" - }, - { - "name": "Coral snake", - "scientific": "Coral snake" - }, - { - "name": "Corn snake", - "scientific": "Corn snake" - }, - { - "name": "Coronado Island rattlesnake", - "scientific": "Crotalus oreganus caliginis" - }, - { - "name": "Crossed viper", - "scientific": "Vipera berus" - }, - { - "name": "Crotalus cerastes", - "scientific": "Crotalus cerastes" - }, - { - "name": "Crotalus durissus", - "scientific": "Crotalus durissus" - }, - { - "name": "Crotalus horridus", - "scientific": "Timber rattlesnake" - }, - { - "name": "Crowned snake", - "scientific": "Tantilla" - }, - { - "name": "Cuban boa", - "scientific": "Chilabothrus angulifer" - }, - { - "name": "Cuban wood snake", - "scientific": "Tropidophis melanurus" - }, - { - "name": "Dasypeltis", - "scientific": "Dasypeltis" - }, - { - "name": "Desert death adder", - "scientific": "Desert death adder" - }, - { - "name": "Desert kingsnake", - "scientific": "Desert kingsnake" - }, - { - "name": "Desert woma python", - "scientific": "Woma python" - }, - { - "name": "Diamond python", - "scientific": "Morelia spilota spilota" - }, - { - "name": "Dog-toothed cat snake", - "scientific": "Boiga cynodon" - }, - { - "name": "Down's tiger snake", - "scientific": "Tiger snake" - }, - { - "name": "Dubois's sea snake", - "scientific": "Aipysurus duboisii" - }, - { - "name": "Durango rock rattlesnake", - "scientific": "Crotalus lepidus klauberi" - }, - { - "name": "Dusty hognose snake", - "scientific": "Hognose" - }, - { - "name": "Dwarf beaked snake", - "scientific": "Dwarf beaked snake" - }, - { - "name": "Dwarf boa", - "scientific": "Boa constrictor" - }, - { - "name": "Dwarf pipe snake", - "scientific": "Anomochilus" - }, - { - "name": "Eastern brown snake", - "scientific": "Eastern brown snake" - }, - { - "name": "Eastern coral snake", - "scientific": "Micrurus fulvius" - }, - { - "name": "Eastern diamondback rattlesnake", - "scientific": "Eastern diamondback rattlesnake" - }, - { - "name": "Eastern green mamba", - "scientific": "Eastern green mamba" - }, - { - "name": "Eastern hognose snake", - "scientific": "Eastern hognose snake" - }, - { - "name": "Eastern mud snake", - "scientific": "Mud snake" - }, - { - "name": "Eastern racer", - "scientific": "Coluber constrictor" - }, - { - "name": "Eastern tiger snake", - "scientific": "Tiger snake" - }, - { - "name": "Eastern water cobra", - "scientific": "Cobra" - }, - { - "name": "Elaps harlequin snake", - "scientific": "Micrurus fulvius" - }, - { - "name": "Eunectes", - "scientific": "Eunectes" - }, - { - "name": "European Smooth Snake", - "scientific": "Smooth snake" - }, - { - "name": "False cobra", - "scientific": "False cobra" - }, - { - "name": "False coral snake", - "scientific": "Coral snake" - }, - { - "name": "False water cobra", - "scientific": "Hydrodynastes gigas" - }, - { - "name": "Fierce snake", - "scientific": "Inland taipan" - }, - { - "name": "Flying snake", - "scientific": "Chrysopelea" - }, - { - "name": "Forest cobra", - "scientific": "Forest cobra" - }, - { - "name": "Forsten's cat snake", - "scientific": "Boiga forsteni" - }, - { - "name": "Fox snake", - "scientific": "Fox snake" - }, - { - "name": "Gaboon viper", - "scientific": "Gaboon viper" - }, - { - "name": "Garter snake", - "scientific": "Garter snake" - }, - { - "name": "Giant Malagasy hognose snake", - "scientific": "Hognose" - }, - { - "name": "Glossy snake", - "scientific": "Glossy snake" - }, - { - "name": "Gold-ringed cat snake", - "scientific": "Boiga dendrophila" - }, - { - "name": "Gold tree cobra", - "scientific": "Pseudohaje goldii" - }, - { - "name": "Golden tree snake", - "scientific": "Chrysopelea ornata" - }, - { - "name": "Gopher snake", - "scientific": "Pituophis catenifer" - }, - { - "name": "Grand Canyon rattlesnake", - "scientific": "Crotalus oreganus abyssus" - }, - { - "name": "Grass snake", - "scientific": "Grass snake" - }, - { - "name": "Gray cat snake", - "scientific": "Boiga ocellata" - }, - { - "name": "Great Plains rat snake", - "scientific": "Pantherophis emoryi" - }, - { - "name": "Green anaconda", - "scientific": "Green anaconda" - }, - { - "name": "Green rat snake", - "scientific": "Rat snake" - }, - { - "name": "Green tree python", - "scientific": "Green tree python" - }, - { - "name": "Grey-banded kingsnake", - "scientific": "Gray-banded kingsnake" - }, - { - "name": "Grey Lora", - "scientific": "Leptophis stimsoni" - }, - { - "name": "Halmahera python", - "scientific": "Morelia tracyae" - }, - { - "name": "Harlequin coral snake", - "scientific": "Micrurus fulvius" - }, - { - "name": "Herald snake", - "scientific": "Caduceus" - }, - { - "name": "High Woods coral snake", - "scientific": "Coral snake" - }, - { - "name": "Hill keelback", - "scientific": "Amphiesma monticola" - }, - { - "name": "Himalayan keelback", - "scientific": "Amphiesma platyceps" - }, - { - "name": "Hognose snake", - "scientific": "Hognose" - }, - { - "name": "Hognosed viper", - "scientific": "Porthidium" - }, - { - "name": "Hook Nosed Sea Snake", - "scientific": "Enhydrina schistosa" - }, - { - "name": "Hoop snake", - "scientific": "Hoop snake" - }, - { - "name": "Hopi rattlesnake", - "scientific": "Crotalus viridis nuntius" - }, - { - "name": "Indian cobra", - "scientific": "Indian cobra" - }, - { - "name": "Indian egg-eater", - "scientific": "Indian egg-eating snake" - }, - { - "name": "Indian flying snake", - "scientific": "Chrysopelea ornata" - }, - { - "name": "Indian krait", - "scientific": "Bungarus" - }, - { - "name": "Indigo snake", - "scientific": "Drymarchon" - }, - { - "name": "Inland carpet python", - "scientific": "Morelia spilota metcalfei" - }, - { - "name": "Inland taipan", - "scientific": "Inland taipan" - }, - { - "name": "Jamaican boa", - "scientific": "Jamaican boa" - }, - { - "name": "Jan's hognose snake", - "scientific": "Hognose" - }, - { - "name": "Japanese forest rat snake", - "scientific": "Euprepiophis conspicillatus" - }, - { - "name": "Japanese rat snake", - "scientific": "Japanese rat snake" - }, - { - "name": "Japanese striped snake", - "scientific": "Japanese striped snake" - }, - { - "name": "Kayaudi dwarf reticulated python", - "scientific": "Reticulated python" - }, - { - "name": "Keelback", - "scientific": "Natricinae" - }, - { - "name": "Khasi Hills keelback", - "scientific": "Amphiesma khasiense" - }, - { - "name": "King Island tiger snake", - "scientific": "Tiger snake" - }, - { - "name": "King brown", - "scientific": "Mulga snake" - }, - { - "name": "King cobra", - "scientific": "King cobra" - }, - { - "name": "King rat snake", - "scientific": "Rat snake" - }, - { - "name": "King snake", - "scientific": "Kingsnake" - }, - { - "name": "Krait", - "scientific": "Bungarus" - }, - { - "name": "Krefft's tiger snake", - "scientific": "Tiger snake" - }, - { - "name": "Lance-headed rattlesnake", - "scientific": "Crotalus polystictus" - }, - { - "name": "Lancehead", - "scientific": "Bothrops" - }, - { - "name": "Large shield snake", - "scientific": "Pseudotyphlops" - }, - { - "name": "Leptophis ahaetulla", - "scientific": "Leptophis ahaetulla" - }, - { - "name": "Lesser black krait", - "scientific": "Lesser black krait" - }, - { - "name": "Long-nosed adder", - "scientific": "Eastern hognose snake" - }, - { - "name": "Long-nosed tree snake", - "scientific": "Western hognose snake" - }, - { - "name": "Long-nosed whip snake", - "scientific": "Ahaetulla nasuta" - }, - { - "name": "Long-tailed rattlesnake", - "scientific": "Rattlesnake" - }, - { - "name": "Longnosed worm snake", - "scientific": "Leptotyphlops macrorhynchus" - }, - { - "name": "Lyre snake", - "scientific": "Trimorphodon" - }, - { - "name": "Madagascar ground boa", - "scientific": "Acrantophis madagascariensis" - }, - { - "name": "Malayan krait", - "scientific": "Bungarus candidus" - }, - { - "name": "Malayan long-glanded coral snake", - "scientific": "Calliophis bivirgata" - }, - { - "name": "Malayan pit viper", - "scientific": "Pit viper" - }, - { - "name": "Mamba", - "scientific": "Mamba" - }, - { - "name": "Mamushi", - "scientific": "Mamushi" - }, - { - "name": "Manchurian Black Water Snake", - "scientific": "Elaphe schrenckii" - }, - { - "name": "Mandarin rat snake", - "scientific": "Mandarin rat snake" - }, - { - "name": "Mangrove snake (disambiguation)", - "scientific": "Mangrove snake" - }, - { - "name": "Many-banded krait", - "scientific": "Many-banded krait" - }, - { - "name": "Many-banded tree snake", - "scientific": "Many-banded tree snake" - }, - { - "name": "Many-spotted cat snake", - "scientific": "Boiga multomaculata" - }, - { - "name": "Massasauga rattlesnake", - "scientific": "Massasauga" - }, - { - "name": "Mexican black kingsnake", - "scientific": "Mexican black kingsnake" - }, - { - "name": "Mexican green rattlesnake", - "scientific": "Crotalus basiliscus" - }, - { - "name": "Mexican hognose snake", - "scientific": "Hognose" - }, - { - "name": "Mexican parrot snake", - "scientific": "Leptophis mexicanus" - }, - { - "name": "Mexican racer", - "scientific": "Coluber constrictor oaxaca" - }, - { - "name": "Mexican vine snake", - "scientific": "Oxybelis aeneus" - }, - { - "name": "Mexican west coast rattlesnake", - "scientific": "Crotalus basiliscus" - }, - { - "name": "Micropechis ikaheka", - "scientific": "Micropechis ikaheka" - }, - { - "name": "Midget faded rattlesnake", - "scientific": "Crotalus oreganus concolor" - }, - { - "name": "Milk snake", - "scientific": "Milk snake" - }, - { - "name": "Moccasin snake", - "scientific": "Agkistrodon piscivorus" - }, - { - "name": "Modest keelback", - "scientific": "Amphiesma modestum" - }, - { - "name": "Mojave desert sidewinder", - "scientific": "Crotalus cerastes" - }, - { - "name": "Mojave rattlesnake", - "scientific": "Crotalus scutulatus" - }, - { - "name": "Mole viper", - "scientific": "Atractaspidinae" - }, - { - "name": "Moluccan flying snake", - "scientific": "Chrysopelea" - }, - { - "name": "Montpellier snake", - "scientific": "Malpolon monspessulanus" - }, - { - "name": "Mud adder", - "scientific": "Mud adder" - }, - { - "name": "Mud snake", - "scientific": "Mud snake" - }, - { - "name": "Mussurana", - "scientific": "Mussurana" - }, - { - "name": "Narrowhead Garter Snake", - "scientific": "Garter snake" - }, - { - "name": "Nicobar Island keelback", - "scientific": "Amphiesma nicobariense" - }, - { - "name": "Nicobar cat snake", - "scientific": "Boiga wallachi" - }, - { - "name": "Night snake", - "scientific": "Night snake" - }, - { - "name": "Nilgiri keelback", - "scientific": "Nilgiri keelback" - }, - { - "name": "North eastern king snake", - "scientific": "Eastern hognose snake" - }, - { - "name": "Northeastern hill krait", - "scientific": "Northeastern hill krait" - }, - { - "name": "Northern black-tailed rattlesnake", - "scientific": "Crotalus molossus" - }, - { - "name": "Northern tree snake", - "scientific": "Dendrelaphis calligastra" - }, - { - "name": "Northern water snake", - "scientific": "Northern water snake" - }, - { - "name": "Northern white-lipped python", - "scientific": "Leiopython" - }, - { - "name": "Oaxacan small-headed rattlesnake", - "scientific": "Crotalus intermedius gloydi" - }, - { - "name": "Okinawan habu", - "scientific": "Okinawan habu" - }, - { - "name": "Olive sea snake", - "scientific": "Aipysurus laevis" - }, - { - "name": "Opheodrys", - "scientific": "Opheodrys" - }, - { - "name": "Orange-collared keelback", - "scientific": "Rhabdophis himalayanus" - }, - { - "name": "Ornate flying snake", - "scientific": "Chrysopelea ornata" - }, - { - "name": "Oxybelis", - "scientific": "Oxybelis" - }, - { - "name": "Palestine viper", - "scientific": "Vipera palaestinae" - }, - { - "name": "Paradise flying snake", - "scientific": "Chrysopelea paradisi" - }, - { - "name": "Parrot snake", - "scientific": "Leptophis ahaetulla" - }, - { - "name": "Patchnose snake", - "scientific": "Salvadora (snake)" - }, - { - "name": "Pelagic sea snake", - "scientific": "Yellow-bellied sea snake" - }, - { - "name": "Peninsula tiger snake", - "scientific": "Tiger snake" - }, - { - "name": "Perrotet's shieldtail snake", - "scientific": "Plectrurus perrotetii" - }, - { - "name": "Persian rat snake", - "scientific": "Rat snake" - }, - { - "name": "Pine snake", - "scientific": "Pine snake" - }, - { - "name": "Pit viper", - "scientific": "Pit viper" - }, - { - "name": "Plains hognose snake", - "scientific": "Western hognose snake" - }, - { - "name": "Prairie kingsnake", - "scientific": "Lampropeltis calligaster" - }, - { - "name": "Pygmy python", - "scientific": "Pygmy python" - }, - { - "name": "Pythonidae", - "scientific": "Pythonidae" - }, - { - "name": "Queen snake", - "scientific": "Queen snake" - }, - { - "name": "Rat snake", - "scientific": "Rat snake" - }, - { - "name": "Rattler", - "scientific": "Rattlesnake" - }, - { - "name": "Rattlesnake", - "scientific": "Rattlesnake" - }, - { - "name": "Red-bellied black snake", - "scientific": "Red-bellied black snake" - }, - { - "name": "Red-headed krait", - "scientific": "Red-headed krait" - }, - { - "name": "Red-necked keelback", - "scientific": "Rhabdophis subminiatus" - }, - { - "name": "Red-tailed bamboo pitviper", - "scientific": "Trimeresurus erythrurus" - }, - { - "name": "Red-tailed boa", - "scientific": "Boa constrictor" - }, - { - "name": "Red-tailed pipe snake", - "scientific": "Cylindrophis ruffus" - }, - { - "name": "Red blood python", - "scientific": "Python brongersmai" - }, - { - "name": "Red diamond rattlesnake", - "scientific": "Crotalus ruber" - }, - { - "name": "Reticulated python", - "scientific": "Reticulated python" - }, - { - "name": "Ribbon snake", - "scientific": "Ribbon snake" - }, - { - "name": "Ringed hognose snake", - "scientific": "Hognose" - }, - { - "name": "Rosy boa", - "scientific": "Rosy boa" - }, - { - "name": "Rough green snake", - "scientific": "Opheodrys aestivus" - }, - { - "name": "Rubber boa", - "scientific": "Rubber boa" - }, - { - "name": "Rufous beaked snake", - "scientific": "Rufous beaked snake" - }, - { - "name": "Russell's viper", - "scientific": "Russell's viper" - }, - { - "name": "San Francisco garter snake", - "scientific": "San Francisco garter snake" - }, - { - "name": "Sand boa", - "scientific": "Erycinae" - }, - { - "name": "Sand viper", - "scientific": "Sand viper" - }, - { - "name": "Saw-scaled viper", - "scientific": "Echis" - }, - { - "name": "Scarlet kingsnake", - "scientific": "Scarlet kingsnake" - }, - { - "name": "Sea snake", - "scientific": "Hydrophiinae" - }, - { - "name": "Selayer reticulated python", - "scientific": "Reticulated python" - }, - { - "name": "Shield-nosed cobra", - "scientific": "Shield-nosed cobra" - }, - { - "name": "Shield-tailed snake", - "scientific": "Uropeltidae" - }, - { - "name": "Sikkim keelback", - "scientific": "Sikkim keelback" - }, - { - "name": "Sind krait", - "scientific": "Sind krait" - }, - { - "name": "Smooth green snake", - "scientific": "Smooth green snake" - }, - { - "name": "South American hognose snake", - "scientific": "Hognose" - }, - { - "name": "South Andaman krait", - "scientific": "South Andaman krait" - }, - { - "name": "South eastern corn snake", - "scientific": "Corn snake" - }, - { - "name": "Southern Pacific rattlesnake", - "scientific": "Crotalus oreganus helleri" - }, - { - "name": "Southern black racer", - "scientific": "Southern black racer" - }, - { - "name": "Southern hognose snake", - "scientific": "Southern hognose snake" - }, - { - "name": "Southern white-lipped python", - "scientific": "Leiopython" - }, - { - "name": "Southwestern blackhead snake", - "scientific": "Tantilla hobartsmithi" - }, - { - "name": "Southwestern carpet python", - "scientific": "Morelia spilota imbricata" - }, - { - "name": "Southwestern speckled rattlesnake", - "scientific": "Crotalus mitchellii pyrrhus" - }, - { - "name": "Speckled hognose snake", - "scientific": "Hognose" - }, - { - "name": "Speckled kingsnake", - "scientific": "Lampropeltis getula holbrooki" - }, - { - "name": "Spectacled cobra", - "scientific": "Indian cobra" - }, - { - "name": "Sri Lanka cat snake", - "scientific": "Boiga ceylonensis" - }, - { - "name": "Stiletto snake", - "scientific": "Atractaspidinae" - }, - { - "name": "Stimson's python", - "scientific": "Stimson's python" - }, - { - "name": "Striped snake", - "scientific": "Japanese striped snake" - }, - { - "name": "Sumatran short-tailed python", - "scientific": "Python curtus" - }, - { - "name": "Sunbeam snake", - "scientific": "Xenopeltis" - }, - { - "name": "Taipan", - "scientific": "Taipan" - }, - { - "name": "Tan racer", - "scientific": "Coluber constrictor etheridgei" - }, - { - "name": "Tancitaran dusky rattlesnake", - "scientific": "Crotalus pusillus" - }, - { - "name": "Tanimbar python", - "scientific": "Reticulated python" - }, - { - "name": "Tasmanian tiger snake", - "scientific": "Tiger snake" - }, - { - "name": "Tawny cat snake", - "scientific": "Boiga ochracea" - }, - { - "name": "Temple pit viper", - "scientific": "Pit viper" - }, - { - "name": "Tentacled snake", - "scientific": "Erpeton tentaculatum" - }, - { - "name": "Texas Coral Snake", - "scientific": "Coral snake" - }, - { - "name": "Texas blind snake", - "scientific": "Leptotyphlops dulcis" - }, - { - "name": "Texas garter snake", - "scientific": "Texas garter snake" - }, - { - "name": "Texas lyre snake", - "scientific": "Trimorphodon biscutatus vilkinsonii" - }, - { - "name": "Texas night snake", - "scientific": "Hypsiglena jani" - }, - { - "name": "Thai cobra", - "scientific": "King cobra" - }, - { - "name": "Three-lined ground snake", - "scientific": "Atractus trilineatus" - }, - { - "name": "Tic polonga", - "scientific": "Russell's viper" - }, - { - "name": "Tiger rattlesnake", - "scientific": "Crotalus tigris" - }, - { - "name": "Tiger snake", - "scientific": "Tiger snake" - }, - { - "name": "Tigre snake", - "scientific": "Spilotes pullatus" - }, - { - "name": "Timber rattlesnake", - "scientific": "Timber rattlesnake" - }, - { - "name": "Tree snake", - "scientific": "Brown tree snake" - }, - { - "name": "Tri-color hognose snake", - "scientific": "Hognose" - }, - { - "name": "Trinket snake", - "scientific": "Trinket snake" - }, - { - "name": "Tropical rattlesnake", - "scientific": "Crotalus durissus" - }, - { - "name": "Twig snake", - "scientific": "Twig snake" - }, - { - "name": "Twin-Barred tree snake", - "scientific": "Banded flying snake" - }, - { - "name": "Twin-spotted rat snake", - "scientific": "Rat snake" - }, - { - "name": "Twin-spotted rattlesnake", - "scientific": "Crotalus pricei" - }, - { - "name": "Uracoan rattlesnake", - "scientific": "Crotalus durissus vegrandis" - }, - { - "name": "Viperidae", - "scientific": "Viperidae" - }, - { - "name": "Wall's keelback", - "scientific": "Amphiesma xenura" - }, - { - "name": "Wart snake", - "scientific": "Acrochordidae" - }, - { - "name": "Water adder", - "scientific": "Agkistrodon piscivorus" - }, - { - "name": "Water moccasin", - "scientific": "Agkistrodon piscivorus" - }, - { - "name": "West Indian racer", - "scientific": "Antiguan racer" - }, - { - "name": "Western blind snake", - "scientific": "Leptotyphlops humilis" - }, - { - "name": "Western carpet python", - "scientific": "Morelia spilota" - }, - { - "name": "Western coral snake", - "scientific": "Coral snake" - }, - { - "name": "Western diamondback rattlesnake", - "scientific": "Western diamondback rattlesnake" - }, - { - "name": "Western green mamba", - "scientific": "Western green mamba" - }, - { - "name": "Western ground snake", - "scientific": "Western ground snake" - }, - { - "name": "Western hognose snake", - "scientific": "Western hognose snake" - }, - { - "name": "Western mud snake", - "scientific": "Mud snake" - }, - { - "name": "Western tiger snake", - "scientific": "Tiger snake" - }, - { - "name": "Western woma python", - "scientific": "Woma python" - }, - { - "name": "White-lipped keelback", - "scientific": "Amphiesma leucomystax" - }, - { - "name": "Wolf snake", - "scientific": "Lycodon capucinus" - }, - { - "name": "Woma python", - "scientific": "Woma python" - }, - { - "name": "Wutu", - "scientific": "Bothrops alternatus" - }, - { - "name": "Wynaad keelback", - "scientific": "Amphiesma monticola" - }, - { - "name": "Yellow-banded sea snake", - "scientific": "Yellow-bellied sea snake" - }, - { - "name": "Yellow-bellied sea snake", - "scientific": "Yellow-bellied sea snake" - }, - { - "name": "Yellow-lipped sea snake", - "scientific": "Yellow-lipped sea krait" - }, - { - "name": "Yellow-striped rat snake", - "scientific": "Rat snake" - }, - { - "name": "Yellow anaconda", - "scientific": "Yellow anaconda" - }, - { - "name": "Yellow cobra", - "scientific": "Cape cobra" - }, - { - "name": "Yunnan keelback", - "scientific": "Amphiesma parallelum" - }, - { - "name": "Abaco Island boa", - "scientific": "Epicrates exsul" - }, - { - "name": "Agkistrodon bilineatus", - "scientific": "Agkistrodon bilineatus" - }, - { - "name": "Amazon tree boa", - "scientific": "Corallus hortulanus" - }, - { - "name": "Andaman cobra", - "scientific": "Andaman cobra" - }, - { - "name": "Angolan python", - "scientific": "Python anchietae" - }, - { - "name": "Arabian cobra", - "scientific": "Arabian cobra" - }, - { - "name": "Asp viper", - "scientific": "Vipera aspis" - }, - { - "name": "Ball Python", - "scientific": "Ball python" - }, - { - "name": "Ball python", - "scientific": "Ball python" - }, - { - "name": "Bamboo pitviper", - "scientific": "Trimeresurus gramineus" - }, - { - "name": "Banded pitviper", - "scientific": "Trimeresurus fasciatus" - }, - { - "name": "Banded water cobra", - "scientific": "Naja annulata" - }, - { - "name": "Barbour's pit viper", - "scientific": "Mixcoatlus barbouri" - }, - { - "name": "Bismarck ringed python", - "scientific": "Bothrochilus" - }, - { - "name": "Black-speckled palm-pitviper", - "scientific": "Bothriechis nigroviridis" - }, - { - "name": "Bluntnose viper", - "scientific": "Macrovipera lebetina" - }, - { - "name": "Bornean pitviper", - "scientific": "Trimeresurus borneensis" - }, - { - "name": "Borneo short-tailed python", - "scientific": "Borneo python" - }, - { - "name": "Bothrops jararacussu", - "scientific": "Bothrops jararacussu" - }, - { - "name": "Bredl's python", - "scientific": "Morelia bredli" - }, - { - "name": "Brongersma's pitviper", - "scientific": "Trimeresurus brongersmai" - }, - { - "name": "Brown spotted pitviper", - "scientific": "Trimeresurus mucrosquamatus" - }, - { - "name": "Brown water python", - "scientific": "Liasis fuscus" - }, - { - "name": "Burrowing cobra", - "scientific": "Egyptian cobra" - }, - { - "name": "Bush viper", - "scientific": "Atheris" - }, - { - "name": "Calabar python", - "scientific": "Calabar python" - }, - { - "name": "Caspian cobra", - "scientific": "Caspian cobra" - }, - { - "name": "Centralian carpet python", - "scientific": "Morelia bredli" - }, - { - "name": "Chinese tree viper", - "scientific": "Trimeresurus stejnegeri" - }, - { - "name": "Coastal carpet python", - "scientific": "Morelia spilota mcdowelli" - }, - { - "name": "Colorado desert sidewinder", - "scientific": "Crotalus cerastes laterorepens" - }, - { - "name": "Common lancehead", - "scientific": "Bothrops atrox" - }, - { - "name": "Cyclades blunt-nosed viper", - "scientific": "Macrovipera schweizeri" - }, - { - "name": "Dauan Island water python", - "scientific": "Liasis fuscus" - }, - { - "name": "De Schauensee's anaconda", - "scientific": "Eunectes deschauenseei" - }, - { - "name": "Dumeril's boa", - "scientific": "Acrantophis dumerili" - }, - { - "name": "Dusky pigmy rattlesnake", - "scientific": "Sistrurus miliarius barbouri" - }, - { - "name": "Dwarf sand adder", - "scientific": "Bitis peringueyi" - }, - { - "name": "Egyptian cobra", - "scientific": "Egyptian cobra" - }, - { - "name": "Elegant pitviper", - "scientific": "Trimeresurus elegans" - }, - { - "name": "Emerald tree boa", - "scientific": "Emerald tree boa" - }, - { - "name": "Equatorial spitting cobra", - "scientific": "Equatorial spitting cobra" - }, - { - "name": "European asp", - "scientific": "Vipera aspis" - }, - { - "name": "Eyelash palm-pitviper", - "scientific": "Bothriechis schlegelii" - }, - { - "name": "Eyelash pit viper", - "scientific": "Bothriechis schlegelii" - }, - { - "name": "Eyelash viper", - "scientific": "Bothriechis schlegelii" - }, - { - "name": "False horned viper", - "scientific": "Pseudocerastes" - }, - { - "name": "Fan-Si-Pan horned pitviper", - "scientific": "Trimeresurus cornutus" - }, - { - "name": "Fea's viper", - "scientific": "Azemiops" - }, - { - "name": "Fifty pacer", - "scientific": "Deinagkistrodon" - }, - { - "name": "Flat-nosed pitviper", - "scientific": "Trimeresurus puniceus" - }, - { - "name": "Godman's pit viper", - "scientific": "Cerrophidion godmani" - }, - { - "name": "Great Lakes bush viper", - "scientific": "Atheris nitschei" - }, - { - "name": "Green palm viper", - "scientific": "Bothriechis lateralis" - }, - { - "name": "Green tree pit viper", - "scientific": "Trimeresurus gramineus" - }, - { - "name": "Guatemalan palm viper", - "scientific": "Bothriechis aurifer" - }, - { - "name": "Guatemalan tree viper", - "scientific": "Bothriechis bicolor" - }, - { - "name": "Hagen's pitviper", - "scientific": "Trimeresurus hageni" - }, - { - "name": "Hairy bush viper", - "scientific": "Atheris hispida" - }, - { - "name": "Himehabu", - "scientific": "Ovophis okinavensis" - }, - { - "name": "Hogg Island boa", - "scientific": "Boa constrictor imperator" - }, - { - "name": "Honduran palm viper", - "scientific": "Bothriechis marchi" - }, - { - "name": "Horned desert viper", - "scientific": "Cerastes cerastes" - }, - { - "name": "Horseshoe pitviper", - "scientific": "Trimeresurus strigatus" - }, - { - "name": "Hundred pacer", - "scientific": "Deinagkistrodon" - }, - { - "name": "Hutton's tree viper", - "scientific": "Tropidolaemus huttoni" - }, - { - "name": "Indian python", - "scientific": "Python molurus" - }, - { - "name": "Indian tree viper", - "scientific": "Trimeresurus gramineus" - }, - { - "name": "Indochinese spitting cobra", - "scientific": "Indochinese spitting cobra" - }, - { - "name": "Indonesian water python", - "scientific": "Liasis mackloti" - }, - { - "name": "Javan spitting cobra", - "scientific": "Javan spitting cobra" - }, - { - "name": "Jerdon's pitviper", - "scientific": "Trimeresurus jerdonii" - }, - { - "name": "Jumping viper", - "scientific": "Atropoides" - }, - { - "name": "Jungle carpet python", - "scientific": "Morelia spilota cheynei" - }, - { - "name": "Kanburian pit viper", - "scientific": "Trimeresurus kanburiensis" - }, - { - "name": "Kaulback's lance-headed pitviper", - "scientific": "Trimeresurus kaulbacki" - }, - { - "name": "Kaznakov's viper", - "scientific": "Vipera kaznakovi" - }, - { - "name": "Kham Plateau pitviper", - "scientific": "Protobothrops xiangchengensis" - }, - { - "name": "Lachesis (genus)", - "scientific": "Lachesis (genus)" - }, - { - "name": "Large-eyed pitviper", - "scientific": "Trimeresurus macrops" - }, - { - "name": "Large-scaled tree viper", - "scientific": "Trimeresurus macrolepis" - }, - { - "name": "Leaf-nosed viper", - "scientific": "Eristicophis" - }, - { - "name": "Leaf viper", - "scientific": "Atheris squamigera" - }, - { - "name": "Levant viper", - "scientific": "Macrovipera lebetina" - }, - { - "name": "Long-nosed viper", - "scientific": "Vipera ammodytes" - }, - { - "name": "Macklot's python", - "scientific": "Liasis mackloti" - }, - { - "name": "Madagascar tree boa", - "scientific": "Sanzinia" - }, - { - "name": "Malabar rock pitviper", - "scientific": "Trimeresurus malabaricus" - }, - { - "name": "Malcolm's tree viper", - "scientific": "Trimeresurus sumatranus malcolmi" - }, - { - "name": "Mandalay cobra", - "scientific": "Mandalay spitting cobra" - }, - { - "name": "Mangrove pit viper", - "scientific": "Trimeresurus purpureomaculatus" - }, - { - "name": "Mangshan pitviper", - "scientific": "Trimeresurus mangshanensis" - }, - { - "name": "McMahon's viper", - "scientific": "Eristicophis" - }, - { - "name": "Mexican palm-pitviper", - "scientific": "Bothriechis rowleyi" - }, - { - "name": "Monocled cobra", - "scientific": "Monocled cobra" - }, - { - "name": "Motuo bamboo pitviper", - "scientific": "Trimeresurus medoensis" - }, - { - "name": "Mozambique spitting cobra", - "scientific": "Mozambique spitting cobra" - }, - { - "name": "Namaqua dwarf adder", - "scientific": "Bitis schneideri" - }, - { - "name": "Namib dwarf sand adder", - "scientific": "Bitis peringueyi" - }, - { - "name": "New Guinea carpet python", - "scientific": "Morelia spilota variegata" - }, - { - "name": "Nicobar bamboo pitviper", - "scientific": "Trimeresurus labialis" - }, - { - "name": "Nitsche's bush viper", - "scientific": "Atheris nitschei" - }, - { - "name": "Nitsche's tree viper", - "scientific": "Atheris nitschei" - }, - { - "name": "Northwestern carpet python", - "scientific": "Morelia spilota variegata" - }, - { - "name": "Nubian spitting cobra", - "scientific": "Nubian spitting cobra" - }, - { - "name": "Oenpelli python", - "scientific": "Oenpelli python" - }, - { - "name": "Olive python", - "scientific": "Liasis olivaceus" - }, - { - "name": "Pallas' viper", - "scientific": "Gloydius halys" - }, - { - "name": "Palm viper", - "scientific": "Bothriechis lateralis" - }, - { - "name": "Papuan python", - "scientific": "Apodora" - }, - { - "name": "Peringuey's adder", - "scientific": "Bitis peringueyi" - }, - { - "name": "Philippine cobra", - "scientific": "Philippine cobra" - }, - { - "name": "Philippine pitviper", - "scientific": "Trimeresurus flavomaculatus" - }, - { - "name": "Pope's tree viper", - "scientific": "Trimeresurus popeorum" - }, - { - "name": "Portuguese viper", - "scientific": "Vipera seoanei" - }, - { - "name": "Puerto Rican boa", - "scientific": "Puerto Rican boa" - }, - { - "name": "Rainbow boa", - "scientific": "Rainbow boa" - }, - { - "name": "Red spitting cobra", - "scientific": "Red spitting cobra" - }, - { - "name": "Rhinoceros viper", - "scientific": "Bitis nasicornis" - }, - { - "name": "Rhombic night adder", - "scientific": "Causus maculatus" - }, - { - "name": "Rinkhals", - "scientific": "Rinkhals" - }, - { - "name": "Rinkhals cobra", - "scientific": "Rinkhals" - }, - { - "name": "River jack", - "scientific": "Bitis nasicornis" - }, - { - "name": "Rough-scaled bush viper", - "scientific": "Atheris hispida" - }, - { - "name": "Rough-scaled python", - "scientific": "Rough-scaled python" - }, - { - "name": "Rough-scaled tree viper", - "scientific": "Atheris hispida" - }, - { - "name": "Royal python", - "scientific": "Ball python" - }, - { - "name": "Rungwe tree viper", - "scientific": "Atheris nitschei rungweensis" - }, - { - "name": "Sakishima habu", - "scientific": "Trimeresurus elegans" - }, - { - "name": "Savu python", - "scientific": "Liasis mackloti savuensis" - }, - { - "name": "Schlegel's viper", - "scientific": "Bothriechis schlegelii" - }, - { - "name": "Schultze's pitviper", - "scientific": "Trimeresurus schultzei" - }, - { - "name": "Sedge viper", - "scientific": "Atheris nitschei" - }, - { - "name": "Sharp-nosed viper", - "scientific": "Deinagkistrodon" - }, - { - "name": "Siamese palm viper", - "scientific": "Trimeresurus puniceus" - }, - { - "name": "Side-striped palm-pitviper", - "scientific": "Bothriechis lateralis" - }, - { - "name": "Snorkel viper", - "scientific": "Deinagkistrodon" - }, - { - "name": "Snouted cobra", - "scientific": "Snouted cobra" - }, - { - "name": "Sonoran sidewinder", - "scientific": "Crotalus cerastes cercobombus" - }, - { - "name": "Southern Indonesian spitting cobra", - "scientific": "Javan spitting cobra" - }, - { - "name": "Southern Philippine cobra", - "scientific": "Samar cobra" - }, - { - "name": "Spiny bush viper", - "scientific": "Atheris hispida" - }, - { - "name": "Spitting cobra", - "scientific": "Spitting cobra" - }, - { - "name": "Spotted python", - "scientific": "Spotted python" - }, - { - "name": "Sri Lankan pit viper", - "scientific": "Trimeresurus trigonocephalus" - }, - { - "name": "Stejneger's bamboo pitviper", - "scientific": "Trimeresurus stejnegeri" - }, - { - "name": "Storm water cobra", - "scientific": "Naja annulata" - }, - { - "name": "Sumatran tree viper", - "scientific": "Trimeresurus sumatranus" - }, - { - "name": "Temple viper", - "scientific": "Tropidolaemus wagleri" - }, - { - "name": "Tibetan bamboo pitviper", - "scientific": "Trimeresurus tibetanus" - }, - { - "name": "Tiger pit viper", - "scientific": "Trimeresurus kanburiensis" - }, - { - "name": "Timor python", - "scientific": "Python timoriensis" - }, - { - "name": "Tokara habu", - "scientific": "Trimeresurus tokarensis" - }, - { - "name": "Tree boa", - "scientific": "Emerald tree boa" - }, - { - "name": "Undulated pit viper", - "scientific": "Ophryacus undulatus" - }, - { - "name": "Ursini's viper", - "scientific": "Vipera ursinii" - }, - { - "name": "Wagler's pit viper", - "scientific": "Tropidolaemus wagleri" - }, - { - "name": "West African brown spitting cobra", - "scientific": "Mozambique spitting cobra" - }, - { - "name": "White-lipped tree viper", - "scientific": "Trimeresurus albolabris" - }, - { - "name": "Wirot's pit viper", - "scientific": "Trimeresurus puniceus" - }, - { - "name": "Yellow-lined palm viper", - "scientific": "Bothriechis lateralis" - }, - { - "name": "Zebra spitting cobra", - "scientific": "Naja nigricincta" - }, - { - "name": "Yarara", - "scientific": "Bothrops jararaca" - }, - { - "name": "Wetar Island python", - "scientific": "Liasis macklot" - }, - { - "name": "Urutus", - "scientific": "Bothrops alternatus" - }, - { - "name": "Titanboa", - "scientific": "Titanoboa" - } -] diff --git a/bot/resources/snakes/snake_quiz.json b/bot/resources/snakes/snake_quiz.json deleted file mode 100644 index 8c426b22..00000000 --- a/bot/resources/snakes/snake_quiz.json +++ /dev/null @@ -1,200 +0,0 @@ -[ - { - "id": 0, - "question": "How long have snakes been roaming the Earth for?", - "options": { - "a": "3 million years", - "b": "30 million years", - "c": "130 million years", - "d": "200 million years" - }, - "answerkey": "c" - }, - { - "id": 1, - "question": "What characteristics do all snakes share?", - "options": { - "a": "They are carnivoes", - "b": "They are all programming languages", - "c": "They're all cold-blooded", - "d": "They are both carnivores and cold-blooded" - }, - "answerkey": "c" - }, - { - "id": 2, - "question": "How do snakes hear?", - "options": { - "a": "With small ears", - "b": "Through their skin", - "c": "Through their tail", - "d": "They don't use their ears at all" - }, - "answerkey": "b" - }, - { - "id": 3, - "question": "What can't snakes see?", - "options": { - "a": "Colour", - "b": "Light", - "c": "Both of the above", - "d": "Other snakes" - }, - "answerkey": "a" - }, - { - "id": 4, - "question": "What unique vision ability do boas and pythons possess?", - "options": { - "a": "Night vision", - "b": "Infrared vision", - "c": "See through walls", - "d": "They don't have vision" - }, - "answerkey": "b" - }, - { - "id": 5, - "question": "How does a snake smell?", - "options": { - "a": "Quite pleasant", - "b": "Through its nose", - "c": "Through its tongues", - "d": "Both through its nose and its tongues" - }, - "answerkey": "d" - }, - { - "id": 6, - "question": "Where are Jacobson's organs located in snakes?", - "options": { - "a": "Mouth", - "b": "Tail", - "c": "Stomach", - "d": "Liver" - }, - "answerkey": "a" - }, - { - "id": 7, - "question": "Snakes have very similar internal organs compared to humans. Snakes, however; lack the following:", - "options": { - "a": "A diaphragm", - "b": "Intestines", - "c": "Lungs", - "d": "Kidney" - }, - "answerkey": "a" - }, - { - "id": 8, - "question": "Snakes have different shaped lungs than humans. What do snakes have?", - "options": { - "a": "An elongated right lung", - "b": "A small left lung", - "c": "Both of the above", - "d": "None of the above" - }, - "answerkey": "c" - }, - { - "id": 9, - "question": "What's true about two-headed snakes?", - "options": { - "a": "They're a myth!", - "b": "They rarely survive in the wild", - "c": "They're very dangerous", - "d": "They can kiss each other" - }, - "answerkey": "b" - }, - { - "id": 10, - "question": "What substance covers a snake's skin?", - "options": { - "a": "Calcium", - "b": "Keratin", - "c": "Copper", - "d": "Iron" - }, - "answerkey": "b" - }, - { - "id": 11, - "question": "What snake doesn't have to have a mate to lay eggs?", - "options": { - "a": "Copperhead", - "b": "Cornsnake", - "c": "Kingsnake", - "d": "Flower pot snake" - }, - "answerkey": "d" - }, - { - "id": 12, - "question": "What snake is the longest?", - "options": { - "a": "Green anaconda", - "b": "Reticulated python", - "c": "King cobra", - "d": "Kingsnake" - }, - "answerkey": "b" - }, - { - "id": 13, - "question": "Though invasive species can now be found in the Everglades, in which three continents are pythons (members of the family Pythonidae) found in the wild?", - "options": { - "a": "Africa, Asia and Australia", - "b": "Africa, Australia and Europe", - "c": "Africa, Australia and South America", - "d": "Africa, Asia and South America" - }, - "answerkey": "a" - }, - { - "id": 14, - "question": "Pythons are held as some of the most dangerous snakes on earth, but are often confused with anacondas. Which of these is *not* a difference between pythons and anacondas?", - "options": { - "a": "Pythons suffocate their prey, anacondas crush them", - "b": "Pythons lay eggs, anacondas give birth to live young", - "c": "Pythons grow longer, anacondas grow heavier", - "d": "Pythons generally spend less time in water than anacondas do" - }, - "answerkey": "a" - }, - { - "id": 15, - "question": "Pythons are unable to chew their food, and so swallow prey whole. Which of these methods is most commonly demonstrated to help a python to swallow large prey?", - "options": { - "a": "The python's stomach pressure is reduced, so prey is sucked in", - "b": "An extra set of upper teeth 'walk' along the prey", - "c": "The python holds its head up, so prey falls into its stomach", - "d": "Prey is pushed against a barrier and is forced down the python's throat" - }, - "answerkey": "b" - }, - { - "id": 16, - "question": "Pythons, like many large constrictors, possess vestigial hind limbs. Whilst these 'spurs' serve no purpose in locomotion, how are they put to use by some male pythons? ", - "options": { - "a": "To store sperm", - "b": "To release pheromones", - "c": "To grip females during mating", - "d": "To fight off rival males" - }, - "answerkey": "c" - }, - { - "id": 17, - "question": "Pythons tend to travel by the rectilinear method (in straight lines) when on land, as opposed to the concertina method (s-shaped movement). Why do large pythons tend not to use the concertina method? ", - "options": { - "a": "Their spine is too inflexible", - "b": "They move too slowly", - "c": "The scales on their backs are too rigid", - "d": "They are too heavy" - }, - "answerkey": "d" - } -] diff --git a/bot/resources/snakes/snakes_and_ladders/banner.jpg b/bot/resources/snakes/snakes_and_ladders/banner.jpg deleted file mode 100644 index 69eaaf12..00000000 Binary files a/bot/resources/snakes/snakes_and_ladders/banner.jpg and /dev/null differ diff --git a/bot/resources/snakes/snakes_and_ladders/board.jpg b/bot/resources/snakes/snakes_and_ladders/board.jpg deleted file mode 100644 index 20032e39..00000000 Binary files a/bot/resources/snakes/snakes_and_ladders/board.jpg and /dev/null differ diff --git a/bot/resources/snakes/special_snakes.json b/bot/resources/snakes/special_snakes.json deleted file mode 100644 index 46214f66..00000000 --- a/bot/resources/snakes/special_snakes.json +++ /dev/null @@ -1,16 +0,0 @@ -[ - { - "name": "Bob Ross", - "info": "Robert Norman Ross (October 29, 1942 – July 4, 1995) was an American painter, art instructor, and television host. He was the creator and host of The Joy of Painting, an instructional television program that aired from 1983 to 1994 on PBS in the United States, and also aired in Canada, Latin America, and Europe.", - "image_list": [ - "https://d3atagt0rnqk7k.cloudfront.net/wp-content/uploads/2016/09/23115633/bob-ross-1-1280x800.jpg" - ] - }, - { - "name": "Mystery Snake", - "info": "The Mystery Snake is rumored to be a thin, serpentine creature that hides in spaghetti dinners. It has yellow, pasta-like scales with a completely smooth texture, and is quite glossy. ", - "image_list": [ - "https://img.thrfun.com/img/080/349/spaghetti_dinner_l1.jpg" - ] - } -] -- cgit v1.2.3 From 02512e43f3d68ffd89654c5f2e9e3e9a27c0c018 Mon Sep 17 00:00:00 2001 From: Janine vN Date: Sun, 5 Sep 2021 00:31:20 -0400 Subject: Move game and fun commands to Fun folder, fix ddg This moves all the fun commands and games into the fun folder. This commit also makes changes to the duck_game. It was setting a footer during an embed init, which is no longer possible with the version of d.py we use. Additionally, an issue with editing an embed that had a local image loaded. The workaround for the time being is to update the message, not the embed. --- bot/exts/evergreen/__init__.py | 0 bot/exts/evergreen/battleship.py | 448 ---------- bot/exts/evergreen/catify.py | 86 -- bot/exts/evergreen/coinflip.py | 53 -- bot/exts/evergreen/connect_four.py | 452 ---------- bot/exts/evergreen/duck_game.py | 356 -------- bot/exts/evergreen/fun.py | 250 ------ bot/exts/evergreen/game.py | 485 ----------- bot/exts/evergreen/magic_8ball.py | 30 - bot/exts/evergreen/minesweeper.py | 270 ------ bot/exts/evergreen/movie.py | 205 ----- bot/exts/evergreen/recommend_game.py | 51 -- bot/exts/evergreen/rps.py | 57 -- bot/exts/evergreen/space.py | 236 ----- bot/exts/evergreen/speedrun.py | 26 - bot/exts/evergreen/status_codes.py | 87 -- bot/exts/evergreen/tic_tac_toe.py | 335 -------- bot/exts/evergreen/trivia_quiz.py | 593 ------------- bot/exts/evergreen/wonder_twins.py | 49 -- bot/exts/evergreen/xkcd.py | 91 -- bot/exts/fun/__init__.py | 0 bot/exts/fun/battleship.py | 448 ++++++++++ bot/exts/fun/catify.py | 86 ++ bot/exts/fun/coinflip.py | 53 ++ bot/exts/fun/connect_four.py | 452 ++++++++++ bot/exts/fun/duck_game.py | 336 ++++++++ bot/exts/fun/fun.py | 250 ++++++ bot/exts/fun/game.py | 485 +++++++++++ bot/exts/fun/magic_8ball.py | 30 + bot/exts/fun/minesweeper.py | 270 ++++++ bot/exts/fun/movie.py | 205 +++++ bot/exts/fun/recommend_game.py | 51 ++ bot/exts/fun/rps.py | 57 ++ bot/exts/fun/space.py | 236 +++++ bot/exts/fun/speedrun.py | 26 + bot/exts/fun/status_codes.py | 87 ++ bot/exts/fun/tic_tac_toe.py | 335 ++++++++ bot/exts/fun/trivia_quiz.py | 593 +++++++++++++ bot/exts/fun/wonder_twins.py | 49 ++ bot/exts/fun/xkcd.py | 91 ++ bot/resources/evergreen/LuckiestGuy-Regular.ttf | Bin 58292 -> 0 bytes bot/resources/evergreen/all_cards.png | Bin 155466 -> 0 bytes bot/resources/evergreen/caesar_info.json | 4 - bot/resources/evergreen/ducks_help_ex.png | Bin 343921 -> 0 bytes .../evergreen/game_recs/chrono_trigger.json | 7 - .../evergreen/game_recs/digimon_world.json | 7 - bot/resources/evergreen/game_recs/doom_2.json | 7 - bot/resources/evergreen/game_recs/skyrim.json | 7 - bot/resources/evergreen/html_colours.json | 150 ---- bot/resources/evergreen/magic8ball.json | 22 - bot/resources/evergreen/speedrun_links.json | 18 - bot/resources/evergreen/trivia_quiz.json | 912 -------------------- bot/resources/evergreen/wonder_twins.yaml | 99 --- bot/resources/evergreen/xkcd_colours.json | 951 --------------------- bot/resources/fun/LuckiestGuy-Regular.ttf | Bin 0 -> 58292 bytes bot/resources/fun/all_cards.png | Bin 0 -> 155466 bytes bot/resources/fun/caesar_info.json | 4 + bot/resources/fun/ducks_help_ex.png | Bin 0 -> 343921 bytes bot/resources/fun/game_recs/chrono_trigger.json | 7 + bot/resources/fun/game_recs/digimon_world.json | 7 + bot/resources/fun/game_recs/doom_2.json | 7 + bot/resources/fun/game_recs/skyrim.json | 7 + bot/resources/fun/html_colours.json | 150 ++++ bot/resources/fun/magic8ball.json | 22 + bot/resources/fun/speedrun_links.json | 18 + bot/resources/fun/trivia_quiz.json | 912 ++++++++++++++++++++ bot/resources/fun/wonder_twins.yaml | 99 +++ bot/resources/fun/xkcd_colours.json | 951 +++++++++++++++++++++ 68 files changed, 6324 insertions(+), 6344 deletions(-) delete mode 100644 bot/exts/evergreen/__init__.py delete mode 100644 bot/exts/evergreen/battleship.py delete mode 100644 bot/exts/evergreen/catify.py delete mode 100644 bot/exts/evergreen/coinflip.py delete mode 100644 bot/exts/evergreen/connect_four.py delete mode 100644 bot/exts/evergreen/duck_game.py delete mode 100644 bot/exts/evergreen/fun.py delete mode 100644 bot/exts/evergreen/game.py delete mode 100644 bot/exts/evergreen/magic_8ball.py delete mode 100644 bot/exts/evergreen/minesweeper.py delete mode 100644 bot/exts/evergreen/movie.py delete mode 100644 bot/exts/evergreen/recommend_game.py delete mode 100644 bot/exts/evergreen/rps.py delete mode 100644 bot/exts/evergreen/space.py delete mode 100644 bot/exts/evergreen/speedrun.py delete mode 100644 bot/exts/evergreen/status_codes.py delete mode 100644 bot/exts/evergreen/tic_tac_toe.py delete mode 100644 bot/exts/evergreen/trivia_quiz.py delete mode 100644 bot/exts/evergreen/wonder_twins.py delete mode 100644 bot/exts/evergreen/xkcd.py create mode 100644 bot/exts/fun/__init__.py create mode 100644 bot/exts/fun/battleship.py create mode 100644 bot/exts/fun/catify.py create mode 100644 bot/exts/fun/coinflip.py create mode 100644 bot/exts/fun/connect_four.py create mode 100644 bot/exts/fun/duck_game.py create mode 100644 bot/exts/fun/fun.py create mode 100644 bot/exts/fun/game.py create mode 100644 bot/exts/fun/magic_8ball.py create mode 100644 bot/exts/fun/minesweeper.py create mode 100644 bot/exts/fun/movie.py create mode 100644 bot/exts/fun/recommend_game.py create mode 100644 bot/exts/fun/rps.py create mode 100644 bot/exts/fun/space.py create mode 100644 bot/exts/fun/speedrun.py create mode 100644 bot/exts/fun/status_codes.py create mode 100644 bot/exts/fun/tic_tac_toe.py create mode 100644 bot/exts/fun/trivia_quiz.py create mode 100644 bot/exts/fun/wonder_twins.py create mode 100644 bot/exts/fun/xkcd.py delete mode 100644 bot/resources/evergreen/LuckiestGuy-Regular.ttf delete mode 100644 bot/resources/evergreen/all_cards.png delete mode 100644 bot/resources/evergreen/caesar_info.json delete mode 100644 bot/resources/evergreen/ducks_help_ex.png delete mode 100644 bot/resources/evergreen/game_recs/chrono_trigger.json delete mode 100644 bot/resources/evergreen/game_recs/digimon_world.json delete mode 100644 bot/resources/evergreen/game_recs/doom_2.json delete mode 100644 bot/resources/evergreen/game_recs/skyrim.json delete mode 100644 bot/resources/evergreen/html_colours.json delete mode 100644 bot/resources/evergreen/magic8ball.json delete mode 100644 bot/resources/evergreen/speedrun_links.json delete mode 100644 bot/resources/evergreen/trivia_quiz.json delete mode 100644 bot/resources/evergreen/wonder_twins.yaml delete mode 100644 bot/resources/evergreen/xkcd_colours.json create mode 100644 bot/resources/fun/LuckiestGuy-Regular.ttf create mode 100644 bot/resources/fun/all_cards.png create mode 100644 bot/resources/fun/caesar_info.json create mode 100644 bot/resources/fun/ducks_help_ex.png create mode 100644 bot/resources/fun/game_recs/chrono_trigger.json create mode 100644 bot/resources/fun/game_recs/digimon_world.json create mode 100644 bot/resources/fun/game_recs/doom_2.json create mode 100644 bot/resources/fun/game_recs/skyrim.json create mode 100644 bot/resources/fun/html_colours.json create mode 100644 bot/resources/fun/magic8ball.json create mode 100644 bot/resources/fun/speedrun_links.json create mode 100644 bot/resources/fun/trivia_quiz.json create mode 100644 bot/resources/fun/wonder_twins.yaml create mode 100644 bot/resources/fun/xkcd_colours.json (limited to 'bot') diff --git a/bot/exts/evergreen/__init__.py b/bot/exts/evergreen/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/bot/exts/evergreen/battleship.py b/bot/exts/evergreen/battleship.py deleted file mode 100644 index f4351954..00000000 --- a/bot/exts/evergreen/battleship.py +++ /dev/null @@ -1,448 +0,0 @@ -import asyncio -import logging -import random -import re -from dataclasses import dataclass -from functools import partial -from typing import Optional - -import discord -from discord.ext import commands - -from bot.bot import Bot -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: Optional[str] - aimed: bool - - -Grid = list[list[Square]] -EmojiSet = dict[tuple[bool, bool], str] - - -@dataclass -class Player: - """Each player in the game - their messages for the boards and their current grid.""" - - user: Optional[discord.Member] - board: Optional[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: Bot, - channel: discord.TextChannel, - player1: discord.Member, - player2: discord.Member - ): - - 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: Optional[discord.Member] = None - self.next: Optional[discord.Member] = None - - self.match: Optional[re.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].upper()) - 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.fullmatch("([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) -> 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: 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: Bot): - self.bot = bot - self.games: list[Game] = [] - self.waiting: 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): - await ctx.send("You're already playing a game!") - return - - if ctx.author in self.waiting: - await ctx.send("You've already sent out a request for a player 2.") - return - - 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() - await ctx.send(f"{ctx.author.mention} Seems like there's no one here to play...") - return - - if str(reaction.emoji) == CROSS_EMOJI: - self.waiting.remove(ctx.author) - await announcement.delete() - await ctx.send(f"{ctx.author.mention} Game cancelled.") - return - - await announcement.delete() - self.waiting.remove(ctx.author) - if self.already_playing(ctx.author): - return - game = Game(self.bot, ctx.channel, ctx.author, user) - self.games.append(game) - try: - 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: Bot) -> None: - """Load the Battleship Cog.""" - bot.add_cog(Battleship(bot)) diff --git a/bot/exts/evergreen/catify.py b/bot/exts/evergreen/catify.py deleted file mode 100644 index 32dfae09..00000000 --- a/bot/exts/evergreen/catify.py +++ /dev/null @@ -1,86 +0,0 @@ -import random -from contextlib import suppress -from typing import Optional - -from discord import AllowedMentions, Embed, Forbidden -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Cats, Colours, NEGATIVE_REPLIES -from bot.utils import helpers - - -class Catify(commands.Cog): - """Cog for the catify command.""" - - @commands.command(aliases=("ᓚᘏᗢify", "ᓚᘏᗢ")) - @commands.cooldown(1, 5, commands.BucketType.user) - async def catify(self, ctx: commands.Context, *, text: Optional[str]) -> None: - """ - Convert the provided text into a cat themed sentence by interspercing cats throughout text. - - If no text is given then the users nickname is edited. - """ - if not text: - display_name = ctx.author.display_name - - if len(display_name) > 26: - embed = Embed( - title=random.choice(NEGATIVE_REPLIES), - description=( - "Your display name is too long to be catified! " - "Please change it to be under 26 characters." - ), - color=Colours.soft_red - ) - await ctx.send(embed=embed) - return - - else: - display_name += f" | {random.choice(Cats.cats)}" - - await ctx.send(f"Your catified nickname is: `{display_name}`", allowed_mentions=AllowedMentions.none()) - - with suppress(Forbidden): - await ctx.author.edit(nick=display_name) - else: - if len(text) >= 1500: - embed = Embed( - title=random.choice(NEGATIVE_REPLIES), - description="Submitted text was too large! Please submit something under 1500 characters.", - color=Colours.soft_red - ) - await ctx.send(embed=embed) - return - - string_list = text.split() - for index, name in enumerate(string_list): - name = name.lower() - if "cat" in name: - if random.randint(0, 5) == 5: - string_list[index] = name.replace("cat", f"**{random.choice(Cats.cats)}**") - else: - string_list[index] = name.replace("cat", random.choice(Cats.cats)) - for element in Cats.cats: - if element in name: - string_list[index] = name.replace(element, "cat") - - string_len = len(string_list) // 3 or len(string_list) - - for _ in range(random.randint(1, string_len)): - # insert cat at random index - if random.randint(0, 5) == 5: - string_list.insert(random.randint(0, len(string_list)), f"**{random.choice(Cats.cats)}**") - else: - string_list.insert(random.randint(0, len(string_list)), random.choice(Cats.cats)) - - text = helpers.suppress_links(" ".join(string_list)) - await ctx.send( - f">>> {text}", - allowed_mentions=AllowedMentions.none() - ) - - -def setup(bot: Bot) -> None: - """Loads the catify cog.""" - bot.add_cog(Catify()) diff --git a/bot/exts/evergreen/coinflip.py b/bot/exts/evergreen/coinflip.py deleted file mode 100644 index 804306bd..00000000 --- a/bot/exts/evergreen/coinflip.py +++ /dev/null @@ -1,53 +0,0 @@ -import random - -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Emojis - - -class CoinSide(commands.Converter): - """Class used to convert the `side` parameter of coinflip command.""" - - HEADS = ("h", "head", "heads") - TAILS = ("t", "tail", "tails") - - async def convert(self, ctx: commands.Context, side: str) -> str: - """Converts the provided `side` into the corresponding string.""" - side = side.lower() - if side in self.HEADS: - return "heads" - - if side in self.TAILS: - return "tails" - - raise commands.BadArgument(f"{side!r} is not a valid coin side.") - - -class CoinFlip(commands.Cog): - """Cog for the CoinFlip command.""" - - @commands.command(name="coinflip", aliases=("flip", "coin", "cf")) - async def coinflip_command(self, ctx: commands.Context, side: CoinSide = None) -> None: - """ - Flips a coin. - - If `side` is provided will state whether you guessed the side correctly. - """ - flipped_side = random.choice(["heads", "tails"]) - - message = f"{ctx.author.mention} flipped **{flipped_side}**. " - if not side: - await ctx.send(message) - return - - if side == flipped_side: - message += f"You guessed correctly! {Emojis.lemon_hyperpleased}" - else: - message += f"You guessed incorrectly. {Emojis.lemon_pensive}" - await ctx.send(message) - - -def setup(bot: Bot) -> None: - """Loads the coinflip cog.""" - bot.add_cog(CoinFlip()) diff --git a/bot/exts/evergreen/connect_four.py b/bot/exts/evergreen/connect_four.py deleted file mode 100644 index 647bb2b7..00000000 --- a/bot/exts/evergreen/connect_four.py +++ /dev/null @@ -1,452 +0,0 @@ -import asyncio -import random -from functools import partial -from typing import Optional, Union - -import discord -import emojis -from discord.ext import commands -from discord.ext.commands import guild_only - -from bot.bot import Bot -from bot.constants import Emojis - -NUMBERS = list(Emojis.number_emojis.values()) -CROSS_EMOJI = Emojis.incident_unactioned - -Coordinate = Optional[tuple[int, int]] -EMOJI_CHECK = Union[discord.Emoji, str] - - -class Game: - """A Connect 4 Game.""" - - def __init__( - self, - bot: Bot, - channel: discord.TextChannel, - player1: discord.Member, - player2: Optional[discord.Member], - tokens: list[str], - size: int = 7 - ): - self.bot = bot - self.channel = channel - self.player1 = player1 - self.player2 = player2 or AI(self.bot, game=self) - self.tokens = tokens - - self.grid = self.generate_board(size) - self.grid_size = size - - self.unicode_numbers = NUMBERS[:self.grid_size] - - self.message = None - - self.player_active = None - self.player_inactive = None - - @staticmethod - def generate_board(size: int) -> list[list[int]]: - """Generate the connect 4 board.""" - return [[0 for _ in range(size)] for _ in range(size)] - - async def print_grid(self) -> None: - """Formats and outputs the Connect Four grid to the channel.""" - title = ( - f"Connect 4: {self.player1.display_name}" - f" VS {self.bot.user.display_name if isinstance(self.player2, AI) else self.player2.display_name}" - ) - - rows = [" ".join(self.tokens[s] for s in row) for row in self.grid] - first_row = " ".join(x for x in NUMBERS[:self.grid_size]) - formatted_grid = "\n".join([first_row] + rows) - embed = discord.Embed(title=title, description=formatted_grid) - - if self.message: - await self.message.edit(embed=embed) - else: - self.message = await self.channel.send(content="Loading...") - for emoji in self.unicode_numbers: - await self.message.add_reaction(emoji) - await self.message.add_reaction(CROSS_EMOJI) - await self.message.edit(content=None, embed=embed) - - async def game_over(self, action: str, player1: discord.user, player2: discord.user) -> None: - """Announces to public chat.""" - if action == "win": - await self.channel.send(f"Game Over! {player1.mention} won against {player2.mention}") - elif action == "draw": - await self.channel.send(f"Game Over! {player1.mention} {player2.mention} It's A Draw :tada:") - elif action == "quit": - await self.channel.send(f"{self.player1.mention} surrendered. Game over!") - await self.print_grid() - - async def start_game(self) -> None: - """Begins the game.""" - self.player_active, self.player_inactive = self.player1, self.player2 - - while True: - await self.print_grid() - - if isinstance(self.player_active, AI): - coords = self.player_active.play() - if not coords: - await self.game_over( - "draw", - self.bot.user if isinstance(self.player_active, AI) else self.player_active, - self.bot.user if isinstance(self.player_inactive, AI) else self.player_inactive, - ) - else: - coords = await self.player_turn() - - if not coords: - return - - if self.check_win(coords, 1 if self.player_active == self.player1 else 2): - await self.game_over( - "win", - self.bot.user if isinstance(self.player_active, AI) else self.player_active, - self.bot.user if isinstance(self.player_inactive, AI) else self.player_inactive, - ) - return - - self.player_active, self.player_inactive = self.player_inactive, self.player_active - - def predicate(self, reaction: discord.Reaction, user: discord.Member) -> bool: - """The predicate to check for the player's reaction.""" - return ( - reaction.message.id == self.message.id - and user.id == self.player_active.id - and str(reaction.emoji) in (*self.unicode_numbers, CROSS_EMOJI) - ) - - async def player_turn(self) -> Coordinate: - """Initiate the player's turn.""" - message = await self.channel.send( - f"{self.player_active.mention}, it's your turn! React with the column you want to place your token in." - ) - player_num = 1 if self.player_active == self.player1 else 2 - while True: - try: - reaction, user = await self.bot.wait_for("reaction_add", check=self.predicate, timeout=30.0) - except asyncio.TimeoutError: - await self.channel.send(f"{self.player_active.mention}, you took too long. Game over!") - return - else: - await message.delete() - if str(reaction.emoji) == CROSS_EMOJI: - await self.game_over("quit", self.player_active, self.player_inactive) - return - - await self.message.remove_reaction(reaction, user) - - column_num = self.unicode_numbers.index(str(reaction.emoji)) - column = [row[column_num] for row in self.grid] - - for row_num, square in reversed(list(enumerate(column))): - if not square: - self.grid[row_num][column_num] = player_num - return row_num, column_num - message = await self.channel.send(f"Column {column_num + 1} is full. Try again") - - def check_win(self, coords: Coordinate, player_num: int) -> bool: - """Check that placing a counter here would cause the player to win.""" - vertical = [(-1, 0), (1, 0)] - horizontal = [(0, 1), (0, -1)] - forward_diag = [(-1, 1), (1, -1)] - backward_diag = [(-1, -1), (1, 1)] - axes = [vertical, horizontal, forward_diag, backward_diag] - - for axis in axes: - counters_in_a_row = 1 # The initial counter that is compared to - for (row_incr, column_incr) in axis: - row, column = coords - row += row_incr - column += column_incr - - while 0 <= row < self.grid_size and 0 <= column < self.grid_size: - if self.grid[row][column] == player_num: - counters_in_a_row += 1 - row += row_incr - column += column_incr - else: - break - if counters_in_a_row >= 4: - return True - return False - - -class AI: - """The Computer Player for Single-Player games.""" - - def __init__(self, bot: Bot, game: Game): - self.game = game - self.mention = bot.user.mention - - def get_possible_places(self) -> list[Coordinate]: - """Gets all the coordinates where the AI could possibly place a counter.""" - possible_coords = [] - for column_num in range(self.game.grid_size): - column = [row[column_num] for row in self.game.grid] - for row_num, square in reversed(list(enumerate(column))): - if not square: - possible_coords.append((row_num, column_num)) - break - return possible_coords - - def check_ai_win(self, coord_list: list[Coordinate]) -> Optional[Coordinate]: - """ - Check AI win. - - Check if placing a counter in any possible coordinate would cause the AI to win - with 10% chance of not winning and returning None - """ - if random.randint(1, 10) == 1: - return - for coords in coord_list: - if self.game.check_win(coords, 2): - return coords - - def check_player_win(self, coord_list: list[Coordinate]) -> Optional[Coordinate]: - """ - Check Player win. - - Check if placing a counter in possible coordinates would stop the player - from winning with 25% of not blocking them and returning None. - """ - if random.randint(1, 4) == 1: - return - for coords in coord_list: - if self.game.check_win(coords, 1): - return coords - - @staticmethod - def random_coords(coord_list: list[Coordinate]) -> Coordinate: - """Picks a random coordinate from the possible ones.""" - return random.choice(coord_list) - - def play(self) -> Union[Coordinate, bool]: - """ - Plays for the AI. - - Gets all possible coords, and determins the move: - 1. coords where it can win. - 2. coords where the player can win. - 3. Random coord - The first possible value is choosen. - """ - possible_coords = self.get_possible_places() - - if not possible_coords: - return False - - coords = ( - self.check_ai_win(possible_coords) - or self.check_player_win(possible_coords) - or self.random_coords(possible_coords) - ) - - row, column = coords - self.game.grid[row][column] = 2 - return coords - - -class ConnectFour(commands.Cog): - """Connect Four. The Classic Vertical Four-in-a-row Game!""" - - def __init__(self, bot: Bot): - self.bot = bot - self.games: list[Game] = [] - self.waiting: list[discord.Member] = [] - - self.tokens = [":white_circle:", ":blue_circle:", ":red_circle:"] - - self.max_board_size = 9 - self.min_board_size = 5 - - async def check_author(self, ctx: commands.Context, board_size: int) -> bool: - """Check if the requester is free and the board size is correct.""" - if self.already_playing(ctx.author): - await ctx.send("You're already playing a game!") - return False - - if ctx.author in self.waiting: - await ctx.send("You've already sent out a request for a player 2") - return False - - if not self.min_board_size <= board_size <= self.max_board_size: - await ctx.send( - f"{board_size} is not a valid board size. A valid board size is " - f"between `{self.min_board_size}` and `{self.max_board_size}`." - ) - return False - - return True - - def get_player( - 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) == Emojis.hand_raised - 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.player1, game.player2) for game in self.games) - - @staticmethod - def check_emojis( - e1: EMOJI_CHECK, e2: EMOJI_CHECK - ) -> tuple[bool, Optional[str]]: - """Validate the emojis, the user put.""" - if isinstance(e1, str) and emojis.count(e1) != 1: - return False, e1 - if isinstance(e2, str) and emojis.count(e2) != 1: - return False, e2 - return True, None - - async def _play_game( - self, - ctx: commands.Context, - user: Optional[discord.Member], - board_size: int, - emoji1: str, - emoji2: str - ) -> None: - """Helper for playing a game of connect four.""" - self.tokens = [":white_circle:", str(emoji1), str(emoji2)] - game = None # if game fails to intialize in try...except - - try: - game = Game(self.bot, ctx.channel, ctx.author, user, self.tokens, size=board_size) - self.games.append(game) - await game.start_game() - self.games.remove(game) - except Exception: - # End the game in the event of an unforeseen error so the players aren't stuck in a game - await ctx.send(f"{ctx.author.mention} {user.mention if user else ''} An error occurred. Game failed.") - if game in self.games: - self.games.remove(game) - raise - - @guild_only() - @commands.group( - invoke_without_command=True, - aliases=("4inarow", "connect4", "connectfour", "c4"), - case_insensitive=True - ) - async def connect_four( - self, - ctx: commands.Context, - board_size: int = 7, - emoji1: EMOJI_CHECK = "\U0001f535", - emoji2: EMOJI_CHECK = "\U0001f534" - ) -> None: - """ - Play the classic game of Connect Four with someone! - - Sets up a message waiting for someone else to react and play along. - The game will start once someone has reacted. - All inputs will be through reactions. - """ - check, emoji = self.check_emojis(emoji1, emoji2) - if not check: - raise commands.EmojiNotFound(emoji) - - check_author_result = await self.check_author(ctx, board_size) - if not check_author_result: - return - - announcement = await ctx.send( - "**Connect Four**: A new game is about to start!\n" - f"Press {Emojis.hand_raised} to play against {ctx.author.mention}!\n" - f"(Cancel the game with {CROSS_EMOJI}.)" - ) - self.waiting.append(ctx.author) - await announcement.add_reaction(Emojis.hand_raised) - await announcement.add_reaction(CROSS_EMOJI) - - try: - reaction, user = await self.bot.wait_for( - "reaction_add", - check=partial(self.get_player, ctx, announcement), - timeout=60.0 - ) - except asyncio.TimeoutError: - self.waiting.remove(ctx.author) - await announcement.delete() - await ctx.send( - f"{ctx.author.mention} Seems like there's no one here to play. " - f"Use `{ctx.prefix}{ctx.invoked_with} ai` to play against a computer." - ) - return - - if str(reaction.emoji) == CROSS_EMOJI: - self.waiting.remove(ctx.author) - await announcement.delete() - await ctx.send(f"{ctx.author.mention} Game cancelled.") - return - - await announcement.delete() - self.waiting.remove(ctx.author) - if self.already_playing(ctx.author): - return - - await self._play_game(ctx, user, board_size, str(emoji1), str(emoji2)) - - @guild_only() - @connect_four.command(aliases=("bot", "computer", "cpu")) - async def ai( - self, - ctx: commands.Context, - board_size: int = 7, - emoji1: EMOJI_CHECK = "\U0001f535", - emoji2: EMOJI_CHECK = "\U0001f534" - ) -> None: - """Play Connect Four against a computer player.""" - check, emoji = self.check_emojis(emoji1, emoji2) - if not check: - raise commands.EmojiNotFound(emoji) - - check_author_result = await self.check_author(ctx, board_size) - if not check_author_result: - return - - await self._play_game(ctx, None, board_size, str(emoji1), str(emoji2)) - - -def setup(bot: Bot) -> None: - """Load ConnectFour Cog.""" - bot.add_cog(ConnectFour(bot)) diff --git a/bot/exts/evergreen/duck_game.py b/bot/exts/evergreen/duck_game.py deleted file mode 100644 index d592f3df..00000000 --- a/bot/exts/evergreen/duck_game.py +++ /dev/null @@ -1,356 +0,0 @@ -import asyncio -import random -import re -from collections import defaultdict -from io import BytesIO -from itertools import product -from pathlib import Path -from urllib.parse import urlparse - -import discord -from PIL import Image, ImageDraw, ImageFont -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Colours, MODERATION_ROLES -from bot.utils.decorators import with_role - - -DECK = list(product(*[(0, 1, 2)]*4)) - -GAME_DURATION = 180 - -# Scoring -CORRECT_SOLN = 1 -INCORRECT_SOLN = -1 -CORRECT_GOOSE = 2 -INCORRECT_GOOSE = -1 - -# Distribution of minimum acceptable solutions at board generation. -# This is for gameplay reasons, to shift the number of solutions per board up, -# while still making the end of the game unpredictable. -# Note: this is *not* the same as the distribution of number of solutions. - -SOLN_DISTR = 0, 0.05, 0.05, 0.1, 0.15, 0.25, 0.2, 0.15, .05 - -IMAGE_PATH = Path("bot", "resources", "evergreen", "all_cards.png") -FONT_PATH = Path("bot", "resources", "evergreen", "LuckiestGuy-Regular.ttf") -HELP_IMAGE_PATH = Path("bot", "resources", "evergreen", "ducks_help_ex.png") - -ALL_CARDS = Image.open(IMAGE_PATH) -LABEL_FONT = ImageFont.truetype(str(FONT_PATH), size=16) -CARD_WIDTH = 155 -CARD_HEIGHT = 97 - -EMOJI_WRONG = "\u274C" - -ANSWER_REGEX = re.compile(r'^\D*(\d+)\D+(\d+)\D+(\d+)\D*$') - -HELP_TEXT = """ -**Each card has 4 features** -Color, Number, Hat, and Accessory - -**A valid flight** -3 cards where each feature is either all the same or all different - -**Call "GOOSE"** -if you think there are no more flights - -**+1** for each valid flight -**+2** for a correct "GOOSE" call -**-1** for any wrong answer - -The first flight below is invalid: the first card has swords while the other two have no accessory.\ - It would be valid if the first card was empty-handed, or one of the other two had paintbrushes. - -The second flight is valid because there are no 2:1 splits; each feature is either all the same or all different. -""" - - -def assemble_board_image(board: list[tuple[int]], rows: int, columns: int) -> Image: - """Cut and paste images representing the given cards into an image representing the board.""" - new_im = Image.new("RGBA", (CARD_WIDTH*columns, CARD_HEIGHT*rows)) - draw = ImageDraw.Draw(new_im) - for idx, card in enumerate(board): - card_image = get_card_image(card) - row, col = divmod(idx, columns) - top, left = row * CARD_HEIGHT, col * CARD_WIDTH - new_im.paste(card_image, (left, top)) - draw.text( - xy=(left+5, top+5), # magic numbers are buffers for the card labels - text=str(idx), - fill=(0, 0, 0), - font=LABEL_FONT, - ) - return new_im - - -def get_card_image(card: tuple[int]) -> Image: - """Slice the image containing all the cards to get just this card.""" - # The master card image file should have 9x9 cards, - # arranged such that their features can be interpreted as ordered trinary. - row, col = divmod(as_trinary(card), 9) - x1 = col * CARD_WIDTH - x2 = x1 + CARD_WIDTH - y1 = row * CARD_HEIGHT - y2 = y1 + CARD_HEIGHT - return ALL_CARDS.crop((x1, y1, x2, y2)) - - -def as_trinary(card: tuple[int]) -> int: - """Find the card's unique index by interpreting its features as trinary.""" - return int(''.join(str(x) for x in card), base=3) - - -class DuckGame: - """A class for a single game.""" - - def __init__( - self, - rows: int = 4, - columns: int = 3, - minimum_solutions: int = 1, - ): - """ - Take samples from the deck to generate a board. - - Args: - rows (int, optional): Rows in the game board. Defaults to 4. - columns (int, optional): Columns in the game board. Defaults to 3. - minimum_solutions (int, optional): Minimum acceptable number of solutions in the board. Defaults to 1. - """ - self.rows = rows - self.columns = columns - size = rows * columns - - self._solutions = None - self.claimed_answers = {} - self.scores = defaultdict(int) - self.editing_embed = asyncio.Lock() - - self.board = random.sample(DECK, size) - while len(self.solutions) < minimum_solutions: - self.board = random.sample(DECK, size) - - @property - def board(self) -> list[tuple[int]]: - """Accesses board property.""" - return self._board - - @board.setter - def board(self, val: list[tuple[int]]) -> None: - """Erases calculated solutions if the board changes.""" - self._solutions = None - self._board = val - - @property - def solutions(self) -> None: - """Calculate valid solutions and cache to avoid redoing work.""" - if self._solutions is None: - self._solutions = set() - for idx_a, card_a in enumerate(self.board): - for idx_b, card_b in enumerate(self.board[idx_a+1:], start=idx_a+1): - # Two points determine a line, and there are exactly 3 points per line in {0,1,2}^4. - # The completion of a line will only be a duplicate point if the other two points are the same, - # which is prevented by the triangle iteration. - completion = tuple( - feat_a if feat_a == feat_b else 3-feat_a-feat_b - for feat_a, feat_b in zip(card_a, card_b) - ) - try: - idx_c = self.board.index(completion) - except ValueError: - continue - - # Indices within the solution are sorted to detect duplicate solutions modulo order. - solution = tuple(sorted((idx_a, idx_b, idx_c))) - self._solutions.add(solution) - - return self._solutions - - -class DuckGamesDirector(commands.Cog): - """A cog for running Duck Duck Duck Goose games.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.current_games = {} - - @commands.group( - name='duckduckduckgoose', - aliases=['dddg', 'ddg', 'duckduckgoose', 'duckgoose'], - invoke_without_command=True - ) - @commands.cooldown(rate=1, per=2, type=commands.BucketType.channel) - async def start_game(self, ctx: commands.Context) -> None: - """Generate a board, send the game embed, and end the game after a time limit.""" - if ctx.channel.id in self.current_games: - await ctx.send("There's already a game running!") - return - - minimum_solutions, = random.choices(range(len(SOLN_DISTR)), weights=SOLN_DISTR) - game = DuckGame(minimum_solutions=minimum_solutions) - game.running = True - self.current_games[ctx.channel.id] = game - - game.embed_msg = await self.send_board_embed(ctx, game) - await asyncio.sleep(GAME_DURATION) - - # Checking for the channel ID in the currently running games is not sufficient. - # The game could have been ended by a player, and a new game already started in the same channel. - if game.running: - try: - del self.current_games[ctx.channel.id] - await self.end_game(ctx.channel, game, end_message="Time's up!") - except KeyError: - pass - - @commands.Cog.listener() - async def on_message(self, msg: discord.Message) -> None: - """Listen for messages and process them as answers if appropriate.""" - if msg.author.bot: - return - - channel = msg.channel - if channel.id not in self.current_games: - return - - game = self.current_games[channel.id] - if msg.content.strip().lower() == 'goose': - # If all of the solutions have been claimed, i.e. the "goose" call is correct. - if len(game.solutions) == len(game.claimed_answers): - try: - del self.current_games[channel.id] - game.scores[msg.author] += CORRECT_GOOSE - await self.end_game(channel, game, end_message=f"{msg.author.display_name} GOOSED!") - except KeyError: - pass - else: - await msg.add_reaction(EMOJI_WRONG) - game.scores[msg.author] += INCORRECT_GOOSE - return - - # Valid answers contain 3 numbers. - if not (match := re.match(ANSWER_REGEX, msg.content)): - return - answer = tuple(sorted(int(m) for m in match.groups())) - - # Be forgiving for answers that use indices not on the board. - if not all(0 <= n < len(game.board) for n in answer): - return - - # Also be forgiving for answers that have already been claimed (and avoid penalizing for racing conditions). - if answer in game.claimed_answers: - return - - if answer in game.solutions: - game.claimed_answers[answer] = msg.author - game.scores[msg.author] += CORRECT_SOLN - await self.display_claimed_answer(game, msg.author, answer) - else: - await msg.add_reaction(EMOJI_WRONG) - game.scores[msg.author] += INCORRECT_SOLN - - async def send_board_embed(self, ctx: commands.Context, game: DuckGame) -> discord.Message: - """Create and send the initial game embed. This will be edited as the game goes on.""" - image = assemble_board_image(game.board, game.rows, game.columns) - with BytesIO() as image_stream: - image.save(image_stream, format="png") - image_stream.seek(0) - file = discord.File(fp=image_stream, filename="board.png") - embed = discord.Embed( - title="Duck Duck Duck Goose!", - color=Colours.bright_green, - footer="" - ) - embed.set_image(url="attachment://board.png") - return await ctx.send(embed=embed, file=file) - - async def display_claimed_answer(self, game: DuckGame, author: discord.Member, answer: tuple[int]) -> None: - """Add a claimed answer to the game embed.""" - async with game.editing_embed: - game_embed, = game.embed_msg.embeds - old_footer = game_embed.footer.text - if old_footer == discord.Embed.Empty: - old_footer = "" - game_embed.set_footer(text=f"{old_footer}\n{str(answer):12s} - {author.display_name}") - await self.edit_embed_with_image(game.embed_msg, game_embed) - - async def end_game(self, channel: discord.TextChannel, game: DuckGame, end_message: str) -> None: - """Edit the game embed to reflect the end of the game and mark the game as not running.""" - game.running = False - - scoreboard_embed = discord.Embed( - title=end_message, - color=discord.Color.dark_purple(), - ) - scores = sorted( - game.scores.items(), - key=lambda item: item[1], - reverse=True, - ) - scoreboard = "Final scores:\n\n" - scoreboard += "\n".join(f"{member.display_name}: {score}" for member, score in scores) - scoreboard_embed.description = scoreboard - await channel.send(embed=scoreboard_embed) - - missed = [ans for ans in game.solutions if ans not in game.claimed_answers] - if missed: - missed_text = "Flights everyone missed:\n" + "\n".join(f"{ans}" for ans in missed) - else: - missed_text = "All the flights were found!" - - game_embed, = game.embed_msg.embeds - old_footer = game_embed.footer.text - if old_footer == discord.Embed.Empty: - old_footer = "" - embed_as_dict = game_embed.to_dict() # Cannot set embed color after initialization - embed_as_dict["color"] = discord.Color.red().value - game_embed = discord.Embed.from_dict(embed_as_dict) - game_embed.set_footer( - text=f"{old_footer.rstrip()}\n\n{missed_text}" - ) - await self.edit_embed_with_image(game.embed_msg, game_embed) - - @start_game.command(name="help") - async def show_rules(self, ctx: commands.Context) -> None: - """Explain the rules of the game.""" - await self.send_help_embed(ctx) - - @start_game.command(name="stop") - @with_role(*MODERATION_ROLES) - async def stop_game(self, ctx: commands.Context) -> None: - """Stop a currently running game. Only available to mods.""" - try: - game = self.current_games.pop(ctx.channel.id) - except KeyError: - await ctx.send("No game currently running in this channel") - return - await self.end_game(ctx.channel, game, end_message="Game canceled.") - - @staticmethod - async def send_help_embed(ctx: commands.Context) -> discord.Message: - """Send rules embed.""" - embed = discord.Embed( - title="Compete against other players to find valid flights!", - color=discord.Color.dark_purple(), - ) - embed.description = HELP_TEXT - file = discord.File(HELP_IMAGE_PATH, filename="help.png") - embed.set_image(url="attachment://help.png") - embed.set_footer( - text="Tip: using Discord's compact message display mode can help keep the board on the screen" - ) - return await ctx.send(file=file, embed=embed) - - @staticmethod - async def edit_embed_with_image(msg: discord.Message, embed: discord.Embed) -> None: - """Edit an embed without the attached image going wonky.""" - attach_name = urlparse(embed.image.url).path.split("/")[-1] - embed.set_image(url=f"attachment://{attach_name}") - await msg.edit(embed=embed) - - -def setup(bot: Bot) -> None: - """Load the DuckGamesDirector cog.""" - bot.add_cog(DuckGamesDirector(bot)) diff --git a/bot/exts/evergreen/fun.py b/bot/exts/evergreen/fun.py deleted file mode 100644 index 4bbfe859..00000000 --- a/bot/exts/evergreen/fun.py +++ /dev/null @@ -1,250 +0,0 @@ -import functools -import json -import logging -import random -from collections.abc import Iterable -from pathlib import Path -from typing import Callable, Optional, Union - -from discord import Embed, Message -from discord.ext import commands -from discord.ext.commands import BadArgument, Cog, Context, MessageConverter, clean_content - -from bot import utils -from bot.bot import Bot -from bot.constants import Client, Colours, Emojis -from bot.utils import helpers - -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", -} - - -def caesar_cipher(text: str, offset: int) -> Iterable[str]: - """ - Implements a lazy Caesar Cipher algorithm. - - Encrypts a `text` given a specific integer `offset`. The sign - of the `offset` dictates the direction in which it shifts to, - with a negative value shifting to the left, and a positive - value shifting to the right. - """ - for char in text: - if not char.isascii() or not char.isalpha() or char.isspace(): - yield char - continue - - case_start = 65 if char.isupper() else 97 - true_offset = (ord(char) - case_start + offset) % 26 - - yield chr(case_start + true_offset) - - -class Fun(Cog): - """A collection of general commands for fun.""" - - def __init__(self, bot: Bot): - self.bot = bot - - self._caesar_cipher_embed = json.loads(Path("bot/resources/evergreen/caesar_info.json").read_text("UTF-8")) - - @staticmethod - def _get_random_die() -> str: - """Generate a random die emoji, ready to be sent on Discord.""" - die_name = f"dice_{random.randint(1, 6)}" - return getattr(Emojis, die_name) - - @commands.command() - async def roll(self, ctx: Context, num_rolls: int = 1) -> None: - """Outputs a number of random dice emotes (up to 6).""" - if 1 <= num_rolls <= 6: - dice = " ".join(self._get_random_die() for _ in range(num_rolls)) - await ctx.send(dice) - else: - raise BadArgument(f"`{Client.prefix}roll` only supports between 1 and 6 rolls.") - - @commands.command(name="uwu", aliases=("uwuwize", "uwuify",)) - async def uwu_command(self, ctx: Context, *, text: clean_content(fix_channel_mentions=True)) -> None: - """Converts a given `text` into it's uwu equivalent.""" - 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) - converted_text = helpers.suppress_links(converted_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: clean_content(fix_channel_mentions=True)) -> None: - """Randomly converts the casing of a given `text`.""" - 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) - converted_text = helpers.suppress_links(converted_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.group(name="caesarcipher", aliases=("caesar", "cc",)) - async def caesarcipher_group(self, ctx: Context) -> None: - """ - Translates a message using the Caesar Cipher. - - See `decrypt`, `encrypt`, and `info` subcommands. - """ - if ctx.invoked_subcommand is None: - await ctx.invoke(self.bot.get_command("help"), "caesarcipher") - - @caesarcipher_group.command(name="info") - async def caesarcipher_info(self, ctx: Context) -> None: - """Information about the Caesar Cipher.""" - embed = Embed.from_dict(self._caesar_cipher_embed) - embed.colour = Colours.dark_green - - await ctx.send(embed=embed) - - @staticmethod - async def _caesar_cipher(ctx: Context, offset: int, msg: str, left_shift: bool = False) -> None: - """ - Given a positive integer `offset`, translates and sends the given `msg`. - - Performs a right shift by default unless `left_shift` is specified as `True`. - - Also accepts a valid Discord Message ID or link. - """ - if offset < 0: - await ctx.send(":no_entry: Cannot use a negative offset.") - return - - if left_shift: - offset = -offset - - def conversion_func(text: str) -> str: - """Encrypts the given string using the Caesar Cipher.""" - return "".join(caesar_cipher(text, offset)) - - text, embed = await Fun._get_text_and_embed(ctx, msg) - - if embed is not None: - embed = Fun._convert_embed(conversion_func, embed) - - converted_text = conversion_func(text) - - if converted_text: - converted_text = f">>> {converted_text.lstrip('> ')}" - - await ctx.send(content=converted_text, embed=embed) - - @caesarcipher_group.command(name="encrypt", aliases=("rightshift", "rshift", "enc",)) - async def caesarcipher_encrypt(self, ctx: Context, offset: int, *, msg: str) -> None: - """ - Given a positive integer `offset`, encrypt the given `msg`. - - Performs a right shift of the letters in the message. - - Also accepts a valid Discord Message ID or link. - """ - await self._caesar_cipher(ctx, offset, msg, left_shift=False) - - @caesarcipher_group.command(name="decrypt", aliases=("leftshift", "lshift", "dec",)) - async def caesarcipher_decrypt(self, ctx: Context, offset: int, *, msg: str) -> None: - """ - Given a positive integer `offset`, decrypt the given `msg`. - - Performs a left shift of the letters in the message. - - Also accepts a valid Discord Message ID or link. - """ - await self._caesar_cipher(ctx, offset, msg, left_shift=True) - - @staticmethod - async def _get_text_and_embed(ctx: Context, text: str) -> tuple[str, Optional[Embed]]: - """ - Attempts to extract the text and embed from a possible link to a discord Message. - - Does not retrieve the text and embed from the Message if it is in a channel the user does - not have read permissions in. - - Returns a tuple of: - str: If `text` is a valid discord Message, the contents of the message, else `text`. - Optional[Embed]: The embed if found in the valid Message, else None - """ - embed = None - - msg = await Fun._get_discord_message(ctx, text) - # Ensure the user has read permissions for the channel the message is in - if isinstance(msg, Message): - permissions = msg.channel.permissions_for(ctx.author) - if permissions.read_messages: - text = msg.clean_content - # Take first embed because we can't send multiple embeds - if msg.embeds: - embed = msg.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: Bot) -> None: - """Load the Fun cog.""" - bot.add_cog(Fun(bot)) diff --git a/bot/exts/evergreen/game.py b/bot/exts/evergreen/game.py deleted file mode 100644 index f9c150e6..00000000 --- a/bot/exts/evergreen/game.py +++ /dev/null @@ -1,485 +0,0 @@ -import difflib -import logging -import random -import re -from asyncio import sleep -from datetime import datetime as dt, timedelta -from enum import IntEnum -from typing import Any, Optional - -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 Bot -from bot.constants import STAFF_ROLES, Tokens -from bot.utils.decorators import with_role -from bot.utils.extensions import invoke_help_command -from bot.utils.pagination import ImagePaginator, LinePaginator - -# Base URL of IGDB API -BASE_URL = "https://api.igdb.com/v4" - -CLIENT_ID = Tokens.igdb_client_id -CLIENT_SECRET = Tokens.igdb_client_secret - -# The number of seconds before expiry that we attempt to re-fetch a new access token -ACCESS_TOKEN_RENEWAL_WINDOW = 60*60*24*2 - -# URL to request API access token -OAUTH_URL = "https://id.twitch.tv/oauth2/token" - -OAUTH_PARAMS = { - "client_id": CLIENT_ID, - "client_secret": CLIENT_SECRET, - "grant_type": "client_credentials" -} - -BASE_HEADERS = { - "Client-ID": CLIENT_ID, - "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: Bot): - self.bot = bot - self.http_session: ClientSession = bot.http_session - - self.genres: dict[str, int] = {} - self.headers = BASE_HEADERS - - self.bot.loop.create_task(self.renew_access_token()) - - async def renew_access_token(self) -> None: - """Refeshes V4 access token a number of seconds before expiry. See `ACCESS_TOKEN_RENEWAL_WINDOW`.""" - while True: - async with self.http_session.post(OAUTH_URL, params=OAUTH_PARAMS) as resp: - result = await resp.json() - if resp.status != 200: - # If there is a valid access token continue to use that, - # otherwise unload cog. - if "Authorization" in self.headers: - time_delta = timedelta(seconds=ACCESS_TOKEN_RENEWAL_WINDOW) - logger.error( - "Failed to renew IGDB access token. " - f"Current token will last for {time_delta} " - f"OAuth response message: {result['message']}" - ) - else: - logger.warning( - "Invalid OAuth credentials. Unloading Games cog. " - f"OAuth response message: {result['message']}" - ) - self.bot.remove_cog("Games") - - return - - self.headers["Authorization"] = f"Bearer {result['access_token']}" - - # Attempt to renew before the token expires - next_renewal = result["expires_in"] - ACCESS_TOKEN_RENEWAL_WINDOW - - time_delta = timedelta(seconds=next_renewal) - logger.info(f"Successfully renewed access token. Refreshing again in {time_delta}") - - # This will be true the first time this loop runs. - # Since we now have an access token, its safe to start this task. - if self.genres == {}: - self.refresh_genres_task.start() - await sleep(next_renewal) - - @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.post(f"{BASE_URL}/genres", data=body, headers=self.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: - """ - 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 - - .games - """ - # When user didn't specified genre, send help message - if genre is None: - await invoke_help_command(ctx) - 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.post(url=f"{BASE_URL}/games", data=body, headers=self.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.post(url=f"{BASE_URL}/games", data=body, headers=self.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.post(url=f"{BASE_URL}/companies", data=body, headers=self.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: Bot) -> None: - """Load the Games cog.""" - # Check does IGDB API key exist, if not, log warning and don't load cog - if not Tokens.igdb_client_id: - logger.warning("No IGDB client ID. Not loading Games cog.") - return - if not Tokens.igdb_client_secret: - logger.warning("No IGDB client secret. Not loading Games cog.") - return - bot.add_cog(Games(bot)) diff --git a/bot/exts/evergreen/magic_8ball.py b/bot/exts/evergreen/magic_8ball.py deleted file mode 100644 index 28ddcea0..00000000 --- a/bot/exts/evergreen/magic_8ball.py +++ /dev/null @@ -1,30 +0,0 @@ -import json -import logging -import random -from pathlib import Path - -from discord.ext import commands - -from bot.bot import Bot - -log = logging.getLogger(__name__) - -ANSWERS = json.loads(Path("bot/resources/evergreen/magic8ball.json").read_text("utf8")) - - -class Magic8ball(commands.Cog): - """A Magic 8ball command to respond to a user's question.""" - - @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(ANSWERS) - await ctx.send(answer) - else: - await ctx.send("Usage: .8ball (minimum length of 3 eg: `will I win?`)") - - -def setup(bot: Bot) -> None: - """Load the Magic8Ball Cog.""" - bot.add_cog(Magic8ball()) diff --git a/bot/exts/evergreen/minesweeper.py b/bot/exts/evergreen/minesweeper.py deleted file mode 100644 index a48b5051..00000000 --- a/bot/exts/evergreen/minesweeper.py +++ /dev/null @@ -1,270 +0,0 @@ -import logging -from collections.abc import Iterator -from dataclasses import dataclass -from random import randint, random -from typing import Union - -import discord -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Client -from bot.utils.converters import CoordinateConverter -from bot.utils.exceptions import UserNotPlayingError -from bot.utils.extensions import invoke_help_command - -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__) - - -GameBoard = list[list[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 - - -class Minesweeper(commands.Cog): - """Play a game of Minesweeper.""" - - def __init__(self): - self.games: dict[int, Game] = {} - - @commands.group(name="minesweeper", aliases=("ms",), invoke_without_command=True) - async def minesweeper_group(self, ctx: commands.Context) -> None: - """Commands for Playing Minesweeper.""" - await invoke_help_command(ctx) - - @staticmethod - def get_neighbours(x: int, y: int) -> Iterator[tuple[int, int]]: - """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 - - try: - 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" - ) - except discord.errors.Forbidden: - log.debug(f"{ctx.author.name} ({ctx.author.id}) has disabled DMs from server members.") - await ctx.send(f":x: {ctx.author.mention}, please enable DMs to play minesweeper.") - return - - # Add game to list - board: GameBoard = self.generate_board(bomb_chance) - revealed_board: GameBoard = [["hidden"] * 10 for _ in range(10)] - dm_msg = await ctx.author.send(f"Here's your board!\n{self.format_for_discord(revealed_board)}") - - if ctx.guild: - await ctx.send(f"{ctx.author.mention} is playing Minesweeper.") - chat_msg = await ctx.send(f"Here's their board!\n{self.format_for_discord(revealed_board)}") - else: - chat_msg = None - - 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 their 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.""" - if ctx.author.id not in self.games: - raise UserNotPlayingError - 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.""" - if ctx.author.id not in self.games: - raise UserNotPlayingError - 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.""" - if ctx.author.id not in self.games: - raise UserNotPlayingError - 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: Bot) -> None: - """Load the Minesweeper cog.""" - bot.add_cog(Minesweeper()) diff --git a/bot/exts/evergreen/movie.py b/bot/exts/evergreen/movie.py deleted file mode 100644 index a04eeb41..00000000 --- a/bot/exts/evergreen/movie.py +++ /dev/null @@ -1,205 +0,0 @@ -import logging -import random -from enum import Enum -from typing import Any - -from aiohttp import ClientSession -from discord import Embed -from discord.ext.commands import Cog, Context, group - -from bot.bot import Bot -from bot.constants import Tokens -from bot.utils.extensions import invoke_help_command -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.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_data(self.http_session, MovieGenres[genre].value, 1) - except KeyError: - await invoke_help_command(ctx) - return - - # Check if "results" is in result. If not, throw error. - if "results" not in result: - 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_data(self.http_session, MovieGenres[genre].value, page) - if "results" not in movies: - 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_data(self, client: ClientSession, genre_id: str, page: int) -> list[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" - - # Make discover request to TMDB, return result - async with client.get(url, params=params) 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[str, Any]: - """Get Movie by movie ID from TMDB. Return result dictionary.""" - if not isinstance(movie, int): - raise ValueError("Error while fetching movie from TMDB, movie argument must be integer. ") - url = BASE_URL + f"movie/{movie}" - - async with client.get(url, params=MOVIE_PARAMS) 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.""" - embed = Embed(title=f"Random {name} Movies") - embed.set_footer(text="This product uses the TMDb API but is not endorsed or certified by TMDb.") - embed.set_thumbnail(url="https://i.imgur.com/LtFtC8H.png") - return embed - - -def setup(bot: Bot) -> None: - """Load the Movie Cog.""" - bot.add_cog(Movie(bot)) diff --git a/bot/exts/evergreen/recommend_game.py b/bot/exts/evergreen/recommend_game.py deleted file mode 100644 index bdd3acb1..00000000 --- a/bot/exts/evergreen/recommend_game.py +++ /dev/null @@ -1,51 +0,0 @@ -import json -import logging -from pathlib import Path -from random import shuffle - -import discord -from discord.ext import commands - -from bot.bot import Bot - -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"): - data = json.loads(rec_path.read_text("utf8")) - game_recs.append(data) -shuffle(game_recs) - - -class RecommendGame(commands.Cog): - """Commands related to recommending games.""" - - def __init__(self, bot: Bot): - 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.display_avatar.url) - embed.set_image(url=game["image"]) - embed.add_field(name=f"Recommendation: {game['title']}\n{game['link']}", value=game["description"]) - - await ctx.send(embed=embed) - - -def setup(bot: Bot) -> None: - """Loads the RecommendGame cog.""" - bot.add_cog(RecommendGame(bot)) diff --git a/bot/exts/evergreen/rps.py b/bot/exts/evergreen/rps.py deleted file mode 100644 index c6bbff46..00000000 --- a/bot/exts/evergreen/rps.py +++ /dev/null @@ -1,57 +0,0 @@ -from random import choice - -from discord.ext import commands - -from bot.bot import Bot - -CHOICES = ["rock", "paper", "scissors"] -SHORT_CHOICES = ["r", "p", "s"] - -# Using a dictionary instead of conditions to check for the winner. -WINNER_DICT = { - "r": { - "r": 0, - "p": -1, - "s": 1, - }, - "p": { - "r": 1, - "p": 0, - "s": -1, - }, - "s": { - "r": -1, - "p": 1, - "s": 0, - } -} - - -class RPS(commands.Cog): - """Rock Paper Scissors. The Classic Game!""" - - @commands.command(case_insensitive=True) - async def rps(self, ctx: commands.Context, move: str) -> None: - """Play the classic game of Rock Paper Scissors with your own sir-lancebot!""" - move = move.lower() - player_mention = ctx.author.mention - - if move not in CHOICES and move not in SHORT_CHOICES: - raise commands.BadArgument(f"Invalid move. Please make move from options: {', '.join(CHOICES).upper()}.") - - bot_move = choice(CHOICES) - # value of player_result will be from (-1, 0, 1) as (lost, tied, won). - player_result = WINNER_DICT[move[0]][bot_move[0]] - - if player_result == 0: - message_string = f"{player_mention} You and Sir Lancebot played {bot_move}, it's a tie." - await ctx.send(message_string) - elif player_result == 1: - await ctx.send(f"Sir Lancebot played {bot_move}! {player_mention} won!") - else: - await ctx.send(f"Sir Lancebot played {bot_move}! {player_mention} lost!") - - -def setup(bot: Bot) -> None: - """Load the RPS Cog.""" - bot.add_cog(RPS(bot)) diff --git a/bot/exts/evergreen/space.py b/bot/exts/evergreen/space.py deleted file mode 100644 index 48ad0f96..00000000 --- a/bot/exts/evergreen/space.py +++ /dev/null @@ -1,236 +0,0 @@ -import logging -import random -from datetime import date, datetime -from typing import Any, Optional -from urllib.parse import urlencode - -from discord import Embed -from discord.ext import tasks -from discord.ext.commands import Cog, Context, group - -from bot.bot import Bot -from bot.constants import Tokens -from bot.utils.converters import DateConverter -from bot.utils.extensions import invoke_help_command - -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" - -APOD_MIN_DATE = date(1995, 6, 16) - - -class Space(Cog): - """Space Cog contains commands, that show images, facts or other information about space.""" - - def __init__(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 invoke_help_command(ctx) - - @space.command(name="apod") - async def apod(self, ctx: Context, date: Optional[str]) -> 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: - apod_date = datetime.strptime(date, "%Y-%m-%d").date() - except ValueError: - await ctx.send(f"Invalid date {date}. Please make sure your date is in format YYYY-MM-DD.") - return - - now = datetime.now().date() - if APOD_MIN_DATE > apod_date or now < apod_date: - await ctx.send(f"Date must be between {APOD_MIN_DATE.isoformat()} and {now.isoformat()} (today).") - return - - params["date"] = apod_date.isoformat() - - 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: - """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: - """Get a random image of the Earth from the 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], - rover: 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: Bot) -> None: - """Load the 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 deleted file mode 100644 index 774eff81..00000000 --- a/bot/exts/evergreen/speedrun.py +++ /dev/null @@ -1,26 +0,0 @@ -import json -import logging -from pathlib import Path -from random import choice - -from discord.ext import commands - -from bot.bot import Bot - -log = logging.getLogger(__name__) - -LINKS = json.loads(Path("bot/resources/evergreen/speedrun_links.json").read_text("utf8")) - - -class Speedrun(commands.Cog): - """Commands about the video game speedrunning community.""" - - @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: Bot) -> None: - """Load the Speedrun cog.""" - bot.add_cog(Speedrun()) diff --git a/bot/exts/evergreen/status_codes.py b/bot/exts/evergreen/status_codes.py deleted file mode 100644 index 501cbe0a..00000000 --- a/bot/exts/evergreen/status_codes.py +++ /dev/null @@ -1,87 +0,0 @@ -from random import choice - -import discord -from discord.ext import commands - -from bot.bot import Bot - -HTTP_DOG_URL = "https://httpstatusdogs.com/img/{code}.jpg" -HTTP_CAT_URL = "https://http.cat/{code}.jpg" -STATUS_TEMPLATE = "**Status: {code}**" -ERR_404 = "Unable to find status floof for {code}." -ERR_UNKNOWN = "Error attempting to retrieve status floof for {code}." -ERROR_LENGTH_EMBED = discord.Embed( - title="Input status code does not exist", - description="The range of valid status codes is 100 to 599", -) - - -class HTTPStatusCodes(commands.Cog): - """ - Fetch an image depicting HTTP status codes as a dog or a cat. - - If neither animal is selected a cat or dog is chosen randomly for the given status code. - """ - - def __init__(self, bot: Bot): - self.bot = bot - - @commands.group( - name="http_status", - aliases=("status", "httpstatus"), - invoke_without_command=True, - ) - async def http_status_group(self, ctx: commands.Context, code: int) -> None: - """Choose a cat or dog randomly for the given status code.""" - subcmd = choice((self.http_cat, self.http_dog)) - await subcmd(ctx, code) - - @http_status_group.command(name="cat") - async def http_cat(self, ctx: commands.Context, code: int) -> None: - """Send a cat version of the requested HTTP status code.""" - if code in range(100, 600): - await self.build_embed(url=HTTP_CAT_URL.format(code=code), ctx=ctx, code=code) - return - await ctx.send(embed=ERROR_LENGTH_EMBED) - - @http_status_group.command(name="dog") - async def http_dog(self, ctx: commands.Context, code: int) -> None: - """Send a dog version of the requested HTTP status code.""" - if code in range(100, 600): - await self.build_embed(url=HTTP_DOG_URL.format(code=code), ctx=ctx, code=code) - return - await ctx.send(embed=ERROR_LENGTH_EMBED) - - async def build_embed(self, url: str, ctx: commands.Context, code: int) -> None: - """Attempt to build and dispatch embed. Append error message instead if something goes wrong.""" - async with self.bot.http_session.get(url, allow_redirects=False) as response: - if response.status in range(200, 300): - await ctx.send( - embed=discord.Embed( - title=STATUS_TEMPLATE.format(code=code) - ).set_image(url=url) - ) - elif response.status in (302, 404): # dog URL returns 302 instead of 404 - if "dog" in url: - await ctx.send( - embed=discord.Embed( - title=ERR_404.format(code=code) - ).set_image(url="https://httpstatusdogs.com/img/404.jpg") - ) - return - await ctx.send( - embed=discord.Embed( - title=ERR_404.format(code=code) - ).set_image(url="https://http.cat/404.jpg") - ) - else: - await ctx.send( - embed=discord.Embed( - title=STATUS_TEMPLATE.format(code=code) - ).set_footer(text=ERR_UNKNOWN.format(code=code)) - ) - - -def setup(bot: Bot) -> None: - """Load the HTTPStatusCodes cog.""" - bot.add_cog(HTTPStatusCodes(bot)) diff --git a/bot/exts/evergreen/tic_tac_toe.py b/bot/exts/evergreen/tic_tac_toe.py deleted file mode 100644 index 5c4f8051..00000000 --- a/bot/exts/evergreen/tic_tac_toe.py +++ /dev/null @@ -1,335 +0,0 @@ -import asyncio -import random -from typing import Callable, Optional, Union - -import discord -from discord.ext.commands import Cog, Context, check, group, guild_only - -from bot.bot import Bot -from bot.constants import Emojis -from bot.utils.pagination import LinePaginator - -CONFIRMATION_MESSAGE = ( - "{opponent}, {requester} wants to play Tic-Tac-Toe against you." - f"\nReact to this message with {Emojis.confirmation} to accept or with {Emojis.decline} to decline." -) - - -def check_win(board: dict[int, str]) -> bool: - """Check from board, is any player won game.""" - return any( - ( - # Horizontal - board[1] == board[2] == board[3], - board[4] == board[5] == board[6], - board[7] == board[8] == board[9], - # Vertical - board[1] == board[4] == board[7], - board[2] == board[5] == board[8], - board[3] == board[6] == board[9], - # Diagonal - board[1] == board[5] == board[9], - board[3] == board[5] == board[7], - ) - ) - - -class Player: - """Class that contains information about player and functions that interact with player.""" - - def __init__(self, user: discord.User, ctx: Context, symbol: str): - self.user = user - self.ctx = ctx - self.symbol = symbol - - async def get_move(self, board: dict[int, str], msg: discord.Message) -> tuple[bool, Optional[int]]: - """ - Get move from user. - - Return is timeout reached and position of field what user will fill when timeout don't reach. - """ - def check_for_move(r: discord.Reaction, u: discord.User) -> bool: - """Check does user who reacted is user who we want, message is board and emoji is in board values.""" - return ( - u.id == self.user.id - and msg.id == r.message.id - and r.emoji in board.values() - and r.emoji in Emojis.number_emojis.values() - ) - - try: - react, _ = await self.ctx.bot.wait_for("reaction_add", timeout=30.0, check=check_for_move) - except asyncio.TimeoutError: - return True, None - else: - return False, list(Emojis.number_emojis.keys())[list(Emojis.number_emojis.values()).index(react.emoji)] - - def __str__(self) -> str: - """Return mention of user.""" - return self.user.mention - - -class AI: - """Tic Tac Toe AI class for against computer gaming.""" - - def __init__(self, symbol: str): - self.symbol = symbol - - async def get_move(self, board: dict[int, str], _: discord.Message) -> tuple[bool, int]: - """Get move from AI. AI use Minimax strategy.""" - possible_moves = [i for i, emoji in board.items() if emoji in list(Emojis.number_emojis.values())] - - for symbol in (Emojis.o_square, Emojis.x_square): - for move in possible_moves: - board_copy = board.copy() - board_copy[move] = symbol - if check_win(board_copy): - return False, move - - open_corners = [i for i in possible_moves if i in (1, 3, 7, 9)] - if len(open_corners) > 0: - return False, random.choice(open_corners) - - if 5 in possible_moves: - return False, 5 - - open_edges = [i for i in possible_moves if i in (2, 4, 6, 8)] - return False, random.choice(open_edges) - - def __str__(self) -> str: - """Return `AI` as user name.""" - return "AI" - - -class Game: - """Class that contains information and functions about Tic Tac Toe game.""" - - def __init__(self, players: list[Union[Player, AI]], ctx: Context): - self.players = players - self.ctx = ctx - self.board = { - 1: Emojis.number_emojis[1], - 2: Emojis.number_emojis[2], - 3: Emojis.number_emojis[3], - 4: Emojis.number_emojis[4], - 5: Emojis.number_emojis[5], - 6: Emojis.number_emojis[6], - 7: Emojis.number_emojis[7], - 8: Emojis.number_emojis[8], - 9: Emojis.number_emojis[9] - } - - self.current = self.players[0] - self.next = self.players[1] - - self.winner: Optional[Union[Player, AI]] = None - self.loser: Optional[Union[Player, AI]] = None - self.over = False - self.canceled = False - self.draw = False - - async def get_confirmation(self) -> tuple[bool, Optional[str]]: - """ - Ask does user want to play TicTacToe against requester. First player is always requester. - - This return tuple that have: - - first element boolean (is game accepted?) - - (optional, only when first element is False, otherwise None) reason for declining. - """ - confirm_message = await self.ctx.send( - CONFIRMATION_MESSAGE.format( - opponent=self.players[1].user.mention, - requester=self.players[0].user.mention - ) - ) - await confirm_message.add_reaction(Emojis.confirmation) - await confirm_message.add_reaction(Emojis.decline) - - def confirm_check(reaction: discord.Reaction, user: discord.User) -> bool: - """Check is user who reacted from who this was requested, message is confirmation and emoji is valid.""" - return ( - reaction.emoji in (Emojis.confirmation, Emojis.decline) - and reaction.message.id == confirm_message.id - and user == self.players[1].user - ) - - try: - reaction, user = await self.ctx.bot.wait_for( - "reaction_add", - timeout=60.0, - check=confirm_check - ) - except asyncio.TimeoutError: - self.over = True - self.canceled = True - await confirm_message.delete() - return False, "Running out of time... Cancelled game." - - await confirm_message.delete() - if reaction.emoji == Emojis.confirmation: - return True, None - else: - self.over = True - self.canceled = True - return False, "User declined" - - async def add_reactions(self, msg: discord.Message) -> None: - """Add number emojis to message.""" - for nr in Emojis.number_emojis.values(): - await msg.add_reaction(nr) - - def format_board(self) -> str: - """Get formatted tic-tac-toe board for message.""" - board = list(self.board.values()) - return "\n".join( - (f"{board[line]} {board[line + 1]} {board[line + 2]}" for line in range(0, len(board), 3)) - ) - - async def play(self) -> None: - """Start and handle game.""" - await self.ctx.send("It's time for the game! Let's begin.") - board = await self.ctx.send( - embed=discord.Embed(description=self.format_board()) - ) - await self.add_reactions(board) - - for _ in range(9): - if isinstance(self.current, Player): - announce = await self.ctx.send( - f"{self.current.user.mention}, it's your turn! " - "React with an emoji to take your go." - ) - timeout, pos = await self.current.get_move(self.board, board) - if isinstance(self.current, Player): - await announce.delete() - if timeout: - await self.ctx.send(f"{self.current.user.mention} ran out of time. Canceling game.") - self.over = True - self.canceled = True - return - self.board[pos] = self.current.symbol - await board.edit( - embed=discord.Embed(description=self.format_board()) - ) - await board.clear_reaction(Emojis.number_emojis[pos]) - if check_win(self.board): - self.winner = self.current - self.loser = self.next - await self.ctx.send( - f":tada: {self.current} won this game! :tada:" - ) - await board.clear_reactions() - break - self.current, self.next = self.next, self.current - if not self.winner: - self.draw = True - await self.ctx.send("It's a DRAW!") - self.over = True - - -def is_channel_free() -> Callable: - """Check is channel where command will be invoked free.""" - async def predicate(ctx: Context) -> bool: - return all(game.channel != ctx.channel for game in ctx.cog.games if not game.over) - return check(predicate) - - -def is_requester_free() -> Callable: - """Check is requester not already in any game.""" - async def predicate(ctx: Context) -> bool: - return all( - ctx.author not in (player.user for player in game.players) for game in ctx.cog.games if not game.over - ) - return check(predicate) - - -class TicTacToe(Cog): - """TicTacToe cog contains tic-tac-toe game commands.""" - - def __init__(self): - self.games: list[Game] = [] - - @guild_only() - @is_channel_free() - @is_requester_free() - @group(name="tictactoe", aliases=("ttt", "tic"), invoke_without_command=True) - async def tic_tac_toe(self, ctx: Context, opponent: Optional[discord.User]) -> None: - """Tic Tac Toe game. Play against friends or AI. Use reactions to add your mark to field.""" - if opponent == ctx.author: - await ctx.send("You can't play against yourself.") - return - if opponent is not None and not all( - opponent not in (player.user for player in g.players) for g in ctx.cog.games if not g.over - ): - await ctx.send("Opponent is already in game.") - return - if opponent is None: - game = Game( - [Player(ctx.author, ctx, Emojis.x_square), AI(Emojis.o_square)], - ctx - ) - else: - game = Game( - [Player(ctx.author, ctx, Emojis.x_square), Player(opponent, ctx, Emojis.o_square)], - ctx - ) - self.games.append(game) - if opponent is not None: - if opponent.bot: # check whether the opponent is a bot or not - await ctx.send("You can't play Tic-Tac-Toe with bots!") - return - - confirmed, msg = await game.get_confirmation() - - if not confirmed: - if msg: - await ctx.send(msg) - return - await game.play() - - @tic_tac_toe.group(name="history", aliases=("log",), invoke_without_command=True) - async def tic_tac_toe_logs(self, ctx: Context) -> None: - """Show most recent tic-tac-toe games.""" - if len(self.games) < 1: - await ctx.send("No recent games.") - return - log_games = [] - for i, game in enumerate(self.games): - if game.over and not game.canceled: - if game.draw: - log_games.append( - f"**#{i+1}**: {game.players[0]} vs {game.players[1]} (draw)" - ) - else: - log_games.append( - f"**#{i+1}**: {game.winner} :trophy: vs {game.loser}" - ) - await LinePaginator.paginate( - log_games, - ctx, - discord.Embed(title="Most recent Tic Tac Toe games") - ) - - @tic_tac_toe_logs.command(name="show", aliases=("s",)) - async def show_tic_tac_toe_board(self, ctx: Context, game_id: int) -> None: - """View game board by ID (ID is possible to get by `.tictactoe history`).""" - if len(self.games) < game_id: - await ctx.send("Game don't exist.") - return - game = self.games[game_id - 1] - - if game.draw: - description = f"{game.players[0]} vs {game.players[1]} (draw)\n\n{game.format_board()}" - else: - description = f"{game.winner} :trophy: vs {game.loser}\n\n{game.format_board()}" - - embed = discord.Embed( - title=f"Match #{game_id} Game Board", - description=description, - ) - await ctx.send(embed=embed) - - -def setup(bot: Bot) -> None: - """Load the TicTacToe cog.""" - bot.add_cog(TicTacToe()) diff --git a/bot/exts/evergreen/trivia_quiz.py b/bot/exts/evergreen/trivia_quiz.py deleted file mode 100644 index aa4020d6..00000000 --- a/bot/exts/evergreen/trivia_quiz.py +++ /dev/null @@ -1,593 +0,0 @@ -import asyncio -import json -import logging -import operator -import random -from dataclasses import dataclass -from pathlib import Path -from typing import Callable, Optional - -import discord -from discord.ext import commands -from rapidfuzz import fuzz - -from bot.bot import Bot -from bot.constants import Colours, NEGATIVE_REPLIES, Roles - -logger = logging.getLogger(__name__) - -DEFAULT_QUESTION_LIMIT = 6 -STANDARD_VARIATION_TOLERANCE = 88 -DYNAMICALLY_GEN_VARIATION_TOLERANCE = 97 - -WRONG_ANS_RESPONSE = [ - "No one answered correctly!", - "Better luck next time...", -] - -N_PREFIX_STARTS_AT = 5 -N_PREFIXES = [ - "penta", "hexa", "hepta", "octa", "nona", - "deca", "hendeca", "dodeca", "trideca", "tetradeca", -] - -PLANETS = [ - ("1st", "Mercury"), - ("2nd", "Venus"), - ("3rd", "Earth"), - ("4th", "Mars"), - ("5th", "Jupiter"), - ("6th", "Saturn"), - ("7th", "Uranus"), - ("8th", "Neptune"), -] - -TAXONOMIC_HIERARCHY = [ - "species", "genus", "family", "order", - "class", "phylum", "kingdom", "domain", -] - -UNITS_TO_BASE_UNITS = { - "hertz": ("(unit of frequency)", "s^-1"), - "newton": ("(unit of force)", "m*kg*s^-2"), - "pascal": ("(unit of pressure & stress)", "m^-1*kg*s^-2"), - "joule": ("(unit of energy & quantity of heat)", "m^2*kg*s^-2"), - "watt": ("(unit of power)", "m^2*kg*s^-3"), - "coulomb": ("(unit of electric charge & quantity of electricity)", "s*A"), - "volt": ("(unit of voltage & electromotive force)", "m^2*kg*s^-3*A^-1"), - "farad": ("(unit of capacitance)", "m^-2*kg^-1*s^4*A^2"), - "ohm": ("(unit of electric resistance)", "m^2*kg*s^-3*A^-2"), - "weber": ("(unit of magnetic flux)", "m^2*kg*s^-2*A^-1"), - "tesla": ("(unit of magnetic flux density)", "kg*s^-2*A^-1"), -} - - -@dataclass(frozen=True) -class QuizEntry: - """Dataclass for a quiz entry (a question and a string containing answers separated by commas).""" - - question: str - answer: str - - -def linear_system(q_format: str, a_format: str) -> QuizEntry: - """Generate a system of linear equations with two unknowns.""" - x, y = random.randint(2, 5), random.randint(2, 5) - answer = a_format.format(x, y) - - coeffs = random.sample(range(1, 6), 4) - - question = q_format.format( - coeffs[0], - coeffs[1], - coeffs[0] * x + coeffs[1] * y, - coeffs[2], - coeffs[3], - coeffs[2] * x + coeffs[3] * y, - ) - - return QuizEntry(question, answer) - - -def mod_arith(q_format: str, a_format: str) -> QuizEntry: - """Generate a basic modular arithmetic question.""" - quotient, m, b = random.randint(30, 40), random.randint(10, 20), random.randint(200, 350) - ans = random.randint(0, 9) # max remainder is 9, since the minimum modulus is 10 - a = quotient * m + ans - b - - question = q_format.format(a, b, m) - answer = a_format.format(ans) - - return QuizEntry(question, answer) - - -def ngonal_prism(q_format: str, a_format: str) -> QuizEntry: - """Generate a question regarding vertices on n-gonal prisms.""" - n = random.randint(0, len(N_PREFIXES) - 1) - - question = q_format.format(N_PREFIXES[n]) - answer = a_format.format((n + N_PREFIX_STARTS_AT) * 2) - - return QuizEntry(question, answer) - - -def imag_sqrt(q_format: str, a_format: str) -> QuizEntry: - """Generate a negative square root question.""" - ans_coeff = random.randint(3, 10) - - question = q_format.format(ans_coeff ** 2) - answer = a_format.format(ans_coeff) - - return QuizEntry(question, answer) - - -def binary_calc(q_format: str, a_format: str) -> QuizEntry: - """Generate a binary calculation question.""" - a = random.randint(15, 20) - b = random.randint(10, a) - oper = random.choice( - ( - ("+", operator.add), - ("-", operator.sub), - ("*", operator.mul), - ) - ) - - # if the operator is multiplication, lower the values of the two operands to make it easier - if oper[0] == "*": - a -= 5 - b -= 5 - - question = q_format.format(a, oper[0], b) - answer = a_format.format(oper[1](a, b)) - - return QuizEntry(question, answer) - - -def solar_system(q_format: str, a_format: str) -> QuizEntry: - """Generate a question on the planets of the Solar System.""" - planet = random.choice(PLANETS) - - question = q_format.format(planet[0]) - answer = a_format.format(planet[1]) - - return QuizEntry(question, answer) - - -def taxonomic_rank(q_format: str, a_format: str) -> QuizEntry: - """Generate a question on taxonomic classification.""" - level = random.randint(0, len(TAXONOMIC_HIERARCHY) - 2) - - question = q_format.format(TAXONOMIC_HIERARCHY[level]) - answer = a_format.format(TAXONOMIC_HIERARCHY[level + 1]) - - return QuizEntry(question, answer) - - -def base_units_convert(q_format: str, a_format: str) -> QuizEntry: - """Generate a SI base units conversion question.""" - unit = random.choice(list(UNITS_TO_BASE_UNITS)) - - question = q_format.format( - unit + " " + UNITS_TO_BASE_UNITS[unit][0] - ) - answer = a_format.format( - UNITS_TO_BASE_UNITS[unit][1] - ) - - return QuizEntry(question, answer) - - -DYNAMIC_QUESTIONS_FORMAT_FUNCS = { - 201: linear_system, - 202: mod_arith, - 203: ngonal_prism, - 204: imag_sqrt, - 205: binary_calc, - 301: solar_system, - 302: taxonomic_rank, - 303: base_units_convert, -} - - -class TriviaQuiz(commands.Cog): - """A cog for all quiz commands.""" - - def __init__(self, bot: Bot): - self.bot = bot - - 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.questions = self.load_questions() - self.question_limit = 0 - - 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.", - "math": "General questions about mathematics ranging from grade 8 to grade 12.", - "science": "Put your understanding of science to the test!", - "cs": "A large variety of computer science questions.", - "python": "Trivia on our amazing language, Python!", - } - - @staticmethod - def load_questions() -> dict: - """Load the questions from the JSON file.""" - p = Path("bot", "resources", "evergreen", "trivia_quiz.json") - - return json.loads(p.read_text(encoding="utf-8")) - - @commands.group(name="quiz", aliases=["trivia"], invoke_without_command=True) - async def quiz_game(self, ctx: commands.Context, category: Optional[str], questions: Optional[int]) -> None: - """ - Start a quiz! - - Questions for the quiz can be selected from the following categories: - - general: Test your general knowledge. - - retro: Questions related to retro gaming. - - math: General questions about mathematics ranging from grade 8 to grade 12. - - science: Put your understanding of science to the test! - - cs: A large variety of computer science questions. - - python: Trivia on our amazing language, Python! - - (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]: - await ctx.send( - "Game is already running... " - f"do `{self.bot.command_prefix}quiz stop`" - ) - return - - # 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 - - topic = self.questions[category] - topic_length = len(topic) - - if questions is None: - self.question_limit = DEFAULT_QUESTION_LIMIT - else: - if questions > topic_length: - await ctx.send( - embed=self.make_error_embed( - f"This category only has {topic_length} questions. " - "Please input a lower value!" - ) - ) - return - - elif questions < 1: - await ctx.send( - embed=self.make_error_embed( - "You must choose to complete at least one question. " - f"(or enter nothing for the default value of {DEFAULT_QUESTION_LIMIT + 1} questions)" - ) - ) - return - - else: - self.question_limit = questions - 1 - - # Start game if not running. - if not self.game_status[ctx.channel.id]: - 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(5) - - done_question = [] - hint_no = 0 - answers = 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 - - if "dynamic_id" not in question_dict: - question = question_dict["question"] - answers = question_dict["answer"].split(", ") - - var_tol = STANDARD_VARIATION_TOLERANCE - else: - format_func = DYNAMIC_QUESTIONS_FORMAT_FUNCS[question_dict["dynamic_id"]] - - quiz_entry = format_func( - question_dict["question"], - question_dict["answer"], - ) - - question, answers = quiz_entry.question, quiz_entry.answer - answers = [answers] - - var_tol = DYNAMICALLY_GEN_VARIATION_TOLERANCE - - embed = discord.Embed( - colour=Colours.gold, - title=f"Question #{len(done_question)}", - description=question, - ) - - if img_url := question_dict.get("img_url"): - embed.set_image(url=img_url) - - await ctx.send(embed=embed) - - def check_func(variation_tolerance: int) -> Callable[[discord.Message], bool]: - def contains_correct_answer(m: discord.Message) -> bool: - return m.channel == ctx.channel and any( - fuzz.ratio(answer.lower(), m.content.lower()) > variation_tolerance - for answer in answers - ) - - return contains_correct_answer - - try: - msg = await self.bot.wait_for("message", check=check_func(var_tol), timeout=10) - except asyncio.TimeoutError: - # In case of TimeoutError and the game has been stopped, then do nothing. - if not self.game_status[ctx.channel.id]: - break - - if hint_no < 2: - hint_no += 1 - - if "hints" in question_dict: - hints = question_dict["hints"] - - await ctx.send(f"**Hint #{hint_no}\n**{hints[hint_no - 1]}") - 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, - answers, - False, - question_dict, - self.question_limit - len(done_question) + 1, - ) - await asyncio.sleep(1) - - hint_no = 0 # Reset the hint counter so that on the next round, it's in the initial state - - 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 - - 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, - answers, - True, - question_dict, - self.question_limit - len(done_question) + 1, - ) - await self.send_score(ctx.channel, self.game_player_scores[ctx.channel.id]) - - await asyncio.sleep(2) - - def make_start_embed(self, category: str) -> discord.Embed: - """Generate a starting/introduction embed for the quiz.""" - start_embed = discord.Embed( - colour=Colours.blue, - title="A quiz game is starting!", - description=( - f"This game consists of {self.question_limit + 1} questions.\n\n" - "**Rules: **\n" - "1. Only enclose your answer in backticks when the question tells you to.\n" - "2. If the question specifies an answer format, follow it or else it won't be accepted.\n" - "3. You have 30s per question. Points for each question reduces by 25 after 10s or after a hint.\n" - "4. No cheating and have fun!\n\n" - f"**Category**: {category}" - ), - ) - - return start_embed - - @staticmethod - def make_error_embed(desc: str) -> discord.Embed: - """Generate an error embed with the given description.""" - error_embed = discord.Embed( - colour=Colours.soft_red, - title=random.choice(NEGATIVE_REPLIES), - description=desc, - ) - - return error_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. - """ - try: - if self.game_status[ctx.channel.id]: - # 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 - ): - self.game_status[ctx.channel.id] = False - del self.game_owners[ctx.channel.id] - self.game_player_scores[ctx.channel.id] = {} - - await ctx.send("Quiz stopped.") - await self.declare_winner(ctx.channel, 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.") - except KeyError: - 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: - """Send the current scores of players in the game channel.""" - if len(player_data) == 0: - await channel.send("No one has made it onto the leaderboard yet.") - return - - embed = discord.Embed( - colour=Colours.blue, - title="Score Board", - description="", - ) - - sorted_dict = sorted(player_data.items(), key=operator.itemgetter(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: - 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: - 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"You 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=Colours.blue, - title="The available question categories are:", - description="", - ) - - embed.set_footer(text="If a category is not chosen, a random one will be selected.") - - for cat, description in self.categories.items(): - embed.description += ( - f"**- {cat.capitalize()}**\n" - f"{description.capitalize()}\n" - ) - - return embed - - @staticmethod - async def send_answer( - channel: discord.TextChannel, - answers: list[str], - answer_is_correct: bool, - question_dict: dict, - q_left: int, - ) -> None: - """Send the correct answer of a question to the game channel.""" - info = question_dict.get("info") - - plurality = " is" if len(answers) == 1 else "s are" - - embed = discord.Embed( - color=Colours.bright_green, - title=( - ("You got it! " if answer_is_correct else "") - + f"The correct answer{plurality} **`{', '.join(answers)}`**\n" - ), - description="", - ) - - if info is not None: - embed.description += f"**Information**\n{info}\n\n" - - embed.description += ( - ("Let's move to the next question." if q_left > 0 else "") - + f"\nRemaining questions: {q_left}" - ) - await channel.send(embed=embed) - - -def setup(bot: Bot) -> None: - """Load the TriviaQuiz cog.""" - bot.add_cog(TriviaQuiz(bot)) diff --git a/bot/exts/evergreen/wonder_twins.py b/bot/exts/evergreen/wonder_twins.py deleted file mode 100644 index 40edf785..00000000 --- a/bot/exts/evergreen/wonder_twins.py +++ /dev/null @@ -1,49 +0,0 @@ -import random -from pathlib import Path - -import yaml -from discord.ext.commands import Cog, Context, command - -from bot.bot import Bot - - -class WonderTwins(Cog): - """Cog for a Wonder Twins inspired command.""" - - def __init__(self): - with open(Path.cwd() / "bot" / "resources" / "evergreen" / "wonder_twins.yaml", "r", encoding="utf-8") as f: - info = yaml.load(f, Loader=yaml.FullLoader) - self.water_types = info["water_types"] - self.objects = info["objects"] - self.adjectives = info["adjectives"] - - @staticmethod - def append_onto(phrase: str, insert_word: str) -> str: - """Appends one word onto the end of another phrase in order to format with the proper determiner.""" - if insert_word.endswith("s"): - phrase = phrase.split() - del phrase[0] - phrase = " ".join(phrase) - - insert_word = insert_word.split()[-1] - return " ".join([phrase, insert_word]) - - def format_phrase(self) -> str: - """Creates a transformation phrase from available words.""" - adjective = random.choice((None, random.choice(self.adjectives))) - object_name = random.choice(self.objects) - water_type = random.choice(self.water_types) - - if adjective: - object_name = self.append_onto(adjective, object_name) - return f"{object_name} of {water_type}" - - @command(name="formof", aliases=("wondertwins", "wondertwin", "fo")) - async def form_of(self, ctx: Context) -> None: - """Command to send a Wonder Twins inspired phrase to the user invoking the command.""" - await ctx.send(f"Form of {self.format_phrase()}!") - - -def setup(bot: Bot) -> None: - """Load the WonderTwins cog.""" - bot.add_cog(WonderTwins()) diff --git a/bot/exts/evergreen/xkcd.py b/bot/exts/evergreen/xkcd.py deleted file mode 100644 index b56c53d9..00000000 --- a/bot/exts/evergreen/xkcd.py +++ /dev/null @@ -1,91 +0,0 @@ -import logging -import re -from random import randint -from typing import Optional, Union - -from discord import Embed -from discord.ext import tasks -from discord.ext.commands import Cog, Context, command - -from bot.bot import Bot -from bot.constants import Colours - -log = logging.getLogger(__name__) - -COMIC_FORMAT = re.compile(r"latest|[0-9]+") -BASE_URL = "https://xkcd.com" - - -class XKCD(Cog): - """Retrieving XKCD comics.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.latest_comic_info: dict[str, Union[str, int]] = {} - self.get_latest_comic_info.start() - - def cog_unload(self) -> None: - """Cancels refreshing of the task for refreshing the most recent comic info.""" - self.get_latest_comic_info.cancel() - - @tasks.loop(minutes=30) - async def get_latest_comic_info(self) -> None: - """Refreshes latest comic's information ever 30 minutes. Also used for finding a random comic.""" - async with self.bot.http_session.get(f"{BASE_URL}/info.0.json") as resp: - if resp.status == 200: - self.latest_comic_info = await resp.json() - else: - log.debug(f"Failed to get latest XKCD comic information. Status code {resp.status}") - - @command(name="xkcd") - async def fetch_xkcd_comics(self, ctx: Context, comic: Optional[str]) -> None: - """ - Getting an xkcd comic's information along with the image. - - To get a random comic, don't type any number as an argument. To get the latest, type 'latest'. - """ - embed = Embed(title=f"XKCD comic '{comic}'") - - embed.colour = Colours.soft_red - - if comic and (comic := re.match(COMIC_FORMAT, comic)) is None: - embed.description = "Comic parameter should either be an integer or 'latest'." - await ctx.send(embed=embed) - return - - comic = randint(1, self.latest_comic_info["num"]) if comic is None else comic.group(0) - - if comic == "latest": - info = self.latest_comic_info - else: - async with self.bot.http_session.get(f"{BASE_URL}/{comic}/info.0.json") as resp: - if resp.status == 200: - info = await resp.json() - else: - embed.title = f"XKCD comic #{comic}" - embed.description = f"{resp.status}: Could not retrieve xkcd comic #{comic}." - log.debug(f"Retrieving xkcd comic #{comic} failed with status code {resp.status}.") - await ctx.send(embed=embed) - return - - embed.title = f"XKCD comic #{info['num']}" - embed.description = info["alt"] - embed.url = f"{BASE_URL}/{info['num']}" - - if info["img"][-3:] in ("jpg", "png", "gif"): - embed.set_image(url=info["img"]) - date = f"{info['year']}/{info['month']}/{info['day']}" - embed.set_footer(text=f"{date} - #{info['num']}, \'{info['safe_title']}\'") - embed.colour = Colours.soft_green - else: - embed.description = ( - "The selected comic is interactive, and cannot be displayed within an embed.\n" - f"Comic can be viewed [here](https://xkcd.com/{info['num']})." - ) - - await ctx.send(embed=embed) - - -def setup(bot: Bot) -> None: - """Load the XKCD cog.""" - bot.add_cog(XKCD(bot)) diff --git a/bot/exts/fun/__init__.py b/bot/exts/fun/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bot/exts/fun/battleship.py b/bot/exts/fun/battleship.py new file mode 100644 index 00000000..f4351954 --- /dev/null +++ b/bot/exts/fun/battleship.py @@ -0,0 +1,448 @@ +import asyncio +import logging +import random +import re +from dataclasses import dataclass +from functools import partial +from typing import Optional + +import discord +from discord.ext import commands + +from bot.bot import Bot +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: Optional[str] + aimed: bool + + +Grid = list[list[Square]] +EmojiSet = dict[tuple[bool, bool], str] + + +@dataclass +class Player: + """Each player in the game - their messages for the boards and their current grid.""" + + user: Optional[discord.Member] + board: Optional[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: Bot, + channel: discord.TextChannel, + player1: discord.Member, + player2: discord.Member + ): + + 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: Optional[discord.Member] = None + self.next: Optional[discord.Member] = None + + self.match: Optional[re.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].upper()) - 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.fullmatch("([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) -> 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: 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: Bot): + self.bot = bot + self.games: list[Game] = [] + self.waiting: 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): + await ctx.send("You're already playing a game!") + return + + if ctx.author in self.waiting: + await ctx.send("You've already sent out a request for a player 2.") + return + + 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() + await ctx.send(f"{ctx.author.mention} Seems like there's no one here to play...") + return + + if str(reaction.emoji) == CROSS_EMOJI: + self.waiting.remove(ctx.author) + await announcement.delete() + await ctx.send(f"{ctx.author.mention} Game cancelled.") + return + + await announcement.delete() + self.waiting.remove(ctx.author) + if self.already_playing(ctx.author): + return + game = Game(self.bot, ctx.channel, ctx.author, user) + self.games.append(game) + try: + 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: Bot) -> None: + """Load the Battleship Cog.""" + bot.add_cog(Battleship(bot)) diff --git a/bot/exts/fun/catify.py b/bot/exts/fun/catify.py new file mode 100644 index 00000000..32dfae09 --- /dev/null +++ b/bot/exts/fun/catify.py @@ -0,0 +1,86 @@ +import random +from contextlib import suppress +from typing import Optional + +from discord import AllowedMentions, Embed, Forbidden +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Cats, Colours, NEGATIVE_REPLIES +from bot.utils import helpers + + +class Catify(commands.Cog): + """Cog for the catify command.""" + + @commands.command(aliases=("ᓚᘏᗢify", "ᓚᘏᗢ")) + @commands.cooldown(1, 5, commands.BucketType.user) + async def catify(self, ctx: commands.Context, *, text: Optional[str]) -> None: + """ + Convert the provided text into a cat themed sentence by interspercing cats throughout text. + + If no text is given then the users nickname is edited. + """ + if not text: + display_name = ctx.author.display_name + + if len(display_name) > 26: + embed = Embed( + title=random.choice(NEGATIVE_REPLIES), + description=( + "Your display name is too long to be catified! " + "Please change it to be under 26 characters." + ), + color=Colours.soft_red + ) + await ctx.send(embed=embed) + return + + else: + display_name += f" | {random.choice(Cats.cats)}" + + await ctx.send(f"Your catified nickname is: `{display_name}`", allowed_mentions=AllowedMentions.none()) + + with suppress(Forbidden): + await ctx.author.edit(nick=display_name) + else: + if len(text) >= 1500: + embed = Embed( + title=random.choice(NEGATIVE_REPLIES), + description="Submitted text was too large! Please submit something under 1500 characters.", + color=Colours.soft_red + ) + await ctx.send(embed=embed) + return + + string_list = text.split() + for index, name in enumerate(string_list): + name = name.lower() + if "cat" in name: + if random.randint(0, 5) == 5: + string_list[index] = name.replace("cat", f"**{random.choice(Cats.cats)}**") + else: + string_list[index] = name.replace("cat", random.choice(Cats.cats)) + for element in Cats.cats: + if element in name: + string_list[index] = name.replace(element, "cat") + + string_len = len(string_list) // 3 or len(string_list) + + for _ in range(random.randint(1, string_len)): + # insert cat at random index + if random.randint(0, 5) == 5: + string_list.insert(random.randint(0, len(string_list)), f"**{random.choice(Cats.cats)}**") + else: + string_list.insert(random.randint(0, len(string_list)), random.choice(Cats.cats)) + + text = helpers.suppress_links(" ".join(string_list)) + await ctx.send( + f">>> {text}", + allowed_mentions=AllowedMentions.none() + ) + + +def setup(bot: Bot) -> None: + """Loads the catify cog.""" + bot.add_cog(Catify()) diff --git a/bot/exts/fun/coinflip.py b/bot/exts/fun/coinflip.py new file mode 100644 index 00000000..804306bd --- /dev/null +++ b/bot/exts/fun/coinflip.py @@ -0,0 +1,53 @@ +import random + +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Emojis + + +class CoinSide(commands.Converter): + """Class used to convert the `side` parameter of coinflip command.""" + + HEADS = ("h", "head", "heads") + TAILS = ("t", "tail", "tails") + + async def convert(self, ctx: commands.Context, side: str) -> str: + """Converts the provided `side` into the corresponding string.""" + side = side.lower() + if side in self.HEADS: + return "heads" + + if side in self.TAILS: + return "tails" + + raise commands.BadArgument(f"{side!r} is not a valid coin side.") + + +class CoinFlip(commands.Cog): + """Cog for the CoinFlip command.""" + + @commands.command(name="coinflip", aliases=("flip", "coin", "cf")) + async def coinflip_command(self, ctx: commands.Context, side: CoinSide = None) -> None: + """ + Flips a coin. + + If `side` is provided will state whether you guessed the side correctly. + """ + flipped_side = random.choice(["heads", "tails"]) + + message = f"{ctx.author.mention} flipped **{flipped_side}**. " + if not side: + await ctx.send(message) + return + + if side == flipped_side: + message += f"You guessed correctly! {Emojis.lemon_hyperpleased}" + else: + message += f"You guessed incorrectly. {Emojis.lemon_pensive}" + await ctx.send(message) + + +def setup(bot: Bot) -> None: + """Loads the coinflip cog.""" + bot.add_cog(CoinFlip()) diff --git a/bot/exts/fun/connect_four.py b/bot/exts/fun/connect_four.py new file mode 100644 index 00000000..647bb2b7 --- /dev/null +++ b/bot/exts/fun/connect_four.py @@ -0,0 +1,452 @@ +import asyncio +import random +from functools import partial +from typing import Optional, Union + +import discord +import emojis +from discord.ext import commands +from discord.ext.commands import guild_only + +from bot.bot import Bot +from bot.constants import Emojis + +NUMBERS = list(Emojis.number_emojis.values()) +CROSS_EMOJI = Emojis.incident_unactioned + +Coordinate = Optional[tuple[int, int]] +EMOJI_CHECK = Union[discord.Emoji, str] + + +class Game: + """A Connect 4 Game.""" + + def __init__( + self, + bot: Bot, + channel: discord.TextChannel, + player1: discord.Member, + player2: Optional[discord.Member], + tokens: list[str], + size: int = 7 + ): + self.bot = bot + self.channel = channel + self.player1 = player1 + self.player2 = player2 or AI(self.bot, game=self) + self.tokens = tokens + + self.grid = self.generate_board(size) + self.grid_size = size + + self.unicode_numbers = NUMBERS[:self.grid_size] + + self.message = None + + self.player_active = None + self.player_inactive = None + + @staticmethod + def generate_board(size: int) -> list[list[int]]: + """Generate the connect 4 board.""" + return [[0 for _ in range(size)] for _ in range(size)] + + async def print_grid(self) -> None: + """Formats and outputs the Connect Four grid to the channel.""" + title = ( + f"Connect 4: {self.player1.display_name}" + f" VS {self.bot.user.display_name if isinstance(self.player2, AI) else self.player2.display_name}" + ) + + rows = [" ".join(self.tokens[s] for s in row) for row in self.grid] + first_row = " ".join(x for x in NUMBERS[:self.grid_size]) + formatted_grid = "\n".join([first_row] + rows) + embed = discord.Embed(title=title, description=formatted_grid) + + if self.message: + await self.message.edit(embed=embed) + else: + self.message = await self.channel.send(content="Loading...") + for emoji in self.unicode_numbers: + await self.message.add_reaction(emoji) + await self.message.add_reaction(CROSS_EMOJI) + await self.message.edit(content=None, embed=embed) + + async def game_over(self, action: str, player1: discord.user, player2: discord.user) -> None: + """Announces to public chat.""" + if action == "win": + await self.channel.send(f"Game Over! {player1.mention} won against {player2.mention}") + elif action == "draw": + await self.channel.send(f"Game Over! {player1.mention} {player2.mention} It's A Draw :tada:") + elif action == "quit": + await self.channel.send(f"{self.player1.mention} surrendered. Game over!") + await self.print_grid() + + async def start_game(self) -> None: + """Begins the game.""" + self.player_active, self.player_inactive = self.player1, self.player2 + + while True: + await self.print_grid() + + if isinstance(self.player_active, AI): + coords = self.player_active.play() + if not coords: + await self.game_over( + "draw", + self.bot.user if isinstance(self.player_active, AI) else self.player_active, + self.bot.user if isinstance(self.player_inactive, AI) else self.player_inactive, + ) + else: + coords = await self.player_turn() + + if not coords: + return + + if self.check_win(coords, 1 if self.player_active == self.player1 else 2): + await self.game_over( + "win", + self.bot.user if isinstance(self.player_active, AI) else self.player_active, + self.bot.user if isinstance(self.player_inactive, AI) else self.player_inactive, + ) + return + + self.player_active, self.player_inactive = self.player_inactive, self.player_active + + def predicate(self, reaction: discord.Reaction, user: discord.Member) -> bool: + """The predicate to check for the player's reaction.""" + return ( + reaction.message.id == self.message.id + and user.id == self.player_active.id + and str(reaction.emoji) in (*self.unicode_numbers, CROSS_EMOJI) + ) + + async def player_turn(self) -> Coordinate: + """Initiate the player's turn.""" + message = await self.channel.send( + f"{self.player_active.mention}, it's your turn! React with the column you want to place your token in." + ) + player_num = 1 if self.player_active == self.player1 else 2 + while True: + try: + reaction, user = await self.bot.wait_for("reaction_add", check=self.predicate, timeout=30.0) + except asyncio.TimeoutError: + await self.channel.send(f"{self.player_active.mention}, you took too long. Game over!") + return + else: + await message.delete() + if str(reaction.emoji) == CROSS_EMOJI: + await self.game_over("quit", self.player_active, self.player_inactive) + return + + await self.message.remove_reaction(reaction, user) + + column_num = self.unicode_numbers.index(str(reaction.emoji)) + column = [row[column_num] for row in self.grid] + + for row_num, square in reversed(list(enumerate(column))): + if not square: + self.grid[row_num][column_num] = player_num + return row_num, column_num + message = await self.channel.send(f"Column {column_num + 1} is full. Try again") + + def check_win(self, coords: Coordinate, player_num: int) -> bool: + """Check that placing a counter here would cause the player to win.""" + vertical = [(-1, 0), (1, 0)] + horizontal = [(0, 1), (0, -1)] + forward_diag = [(-1, 1), (1, -1)] + backward_diag = [(-1, -1), (1, 1)] + axes = [vertical, horizontal, forward_diag, backward_diag] + + for axis in axes: + counters_in_a_row = 1 # The initial counter that is compared to + for (row_incr, column_incr) in axis: + row, column = coords + row += row_incr + column += column_incr + + while 0 <= row < self.grid_size and 0 <= column < self.grid_size: + if self.grid[row][column] == player_num: + counters_in_a_row += 1 + row += row_incr + column += column_incr + else: + break + if counters_in_a_row >= 4: + return True + return False + + +class AI: + """The Computer Player for Single-Player games.""" + + def __init__(self, bot: Bot, game: Game): + self.game = game + self.mention = bot.user.mention + + def get_possible_places(self) -> list[Coordinate]: + """Gets all the coordinates where the AI could possibly place a counter.""" + possible_coords = [] + for column_num in range(self.game.grid_size): + column = [row[column_num] for row in self.game.grid] + for row_num, square in reversed(list(enumerate(column))): + if not square: + possible_coords.append((row_num, column_num)) + break + return possible_coords + + def check_ai_win(self, coord_list: list[Coordinate]) -> Optional[Coordinate]: + """ + Check AI win. + + Check if placing a counter in any possible coordinate would cause the AI to win + with 10% chance of not winning and returning None + """ + if random.randint(1, 10) == 1: + return + for coords in coord_list: + if self.game.check_win(coords, 2): + return coords + + def check_player_win(self, coord_list: list[Coordinate]) -> Optional[Coordinate]: + """ + Check Player win. + + Check if placing a counter in possible coordinates would stop the player + from winning with 25% of not blocking them and returning None. + """ + if random.randint(1, 4) == 1: + return + for coords in coord_list: + if self.game.check_win(coords, 1): + return coords + + @staticmethod + def random_coords(coord_list: list[Coordinate]) -> Coordinate: + """Picks a random coordinate from the possible ones.""" + return random.choice(coord_list) + + def play(self) -> Union[Coordinate, bool]: + """ + Plays for the AI. + + Gets all possible coords, and determins the move: + 1. coords where it can win. + 2. coords where the player can win. + 3. Random coord + The first possible value is choosen. + """ + possible_coords = self.get_possible_places() + + if not possible_coords: + return False + + coords = ( + self.check_ai_win(possible_coords) + or self.check_player_win(possible_coords) + or self.random_coords(possible_coords) + ) + + row, column = coords + self.game.grid[row][column] = 2 + return coords + + +class ConnectFour(commands.Cog): + """Connect Four. The Classic Vertical Four-in-a-row Game!""" + + def __init__(self, bot: Bot): + self.bot = bot + self.games: list[Game] = [] + self.waiting: list[discord.Member] = [] + + self.tokens = [":white_circle:", ":blue_circle:", ":red_circle:"] + + self.max_board_size = 9 + self.min_board_size = 5 + + async def check_author(self, ctx: commands.Context, board_size: int) -> bool: + """Check if the requester is free and the board size is correct.""" + if self.already_playing(ctx.author): + await ctx.send("You're already playing a game!") + return False + + if ctx.author in self.waiting: + await ctx.send("You've already sent out a request for a player 2") + return False + + if not self.min_board_size <= board_size <= self.max_board_size: + await ctx.send( + f"{board_size} is not a valid board size. A valid board size is " + f"between `{self.min_board_size}` and `{self.max_board_size}`." + ) + return False + + return True + + def get_player( + 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) == Emojis.hand_raised + 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.player1, game.player2) for game in self.games) + + @staticmethod + def check_emojis( + e1: EMOJI_CHECK, e2: EMOJI_CHECK + ) -> tuple[bool, Optional[str]]: + """Validate the emojis, the user put.""" + if isinstance(e1, str) and emojis.count(e1) != 1: + return False, e1 + if isinstance(e2, str) and emojis.count(e2) != 1: + return False, e2 + return True, None + + async def _play_game( + self, + ctx: commands.Context, + user: Optional[discord.Member], + board_size: int, + emoji1: str, + emoji2: str + ) -> None: + """Helper for playing a game of connect four.""" + self.tokens = [":white_circle:", str(emoji1), str(emoji2)] + game = None # if game fails to intialize in try...except + + try: + game = Game(self.bot, ctx.channel, ctx.author, user, self.tokens, size=board_size) + self.games.append(game) + await game.start_game() + self.games.remove(game) + except Exception: + # End the game in the event of an unforeseen error so the players aren't stuck in a game + await ctx.send(f"{ctx.author.mention} {user.mention if user else ''} An error occurred. Game failed.") + if game in self.games: + self.games.remove(game) + raise + + @guild_only() + @commands.group( + invoke_without_command=True, + aliases=("4inarow", "connect4", "connectfour", "c4"), + case_insensitive=True + ) + async def connect_four( + self, + ctx: commands.Context, + board_size: int = 7, + emoji1: EMOJI_CHECK = "\U0001f535", + emoji2: EMOJI_CHECK = "\U0001f534" + ) -> None: + """ + Play the classic game of Connect Four with someone! + + Sets up a message waiting for someone else to react and play along. + The game will start once someone has reacted. + All inputs will be through reactions. + """ + check, emoji = self.check_emojis(emoji1, emoji2) + if not check: + raise commands.EmojiNotFound(emoji) + + check_author_result = await self.check_author(ctx, board_size) + if not check_author_result: + return + + announcement = await ctx.send( + "**Connect Four**: A new game is about to start!\n" + f"Press {Emojis.hand_raised} to play against {ctx.author.mention}!\n" + f"(Cancel the game with {CROSS_EMOJI}.)" + ) + self.waiting.append(ctx.author) + await announcement.add_reaction(Emojis.hand_raised) + await announcement.add_reaction(CROSS_EMOJI) + + try: + reaction, user = await self.bot.wait_for( + "reaction_add", + check=partial(self.get_player, ctx, announcement), + timeout=60.0 + ) + except asyncio.TimeoutError: + self.waiting.remove(ctx.author) + await announcement.delete() + await ctx.send( + f"{ctx.author.mention} Seems like there's no one here to play. " + f"Use `{ctx.prefix}{ctx.invoked_with} ai` to play against a computer." + ) + return + + if str(reaction.emoji) == CROSS_EMOJI: + self.waiting.remove(ctx.author) + await announcement.delete() + await ctx.send(f"{ctx.author.mention} Game cancelled.") + return + + await announcement.delete() + self.waiting.remove(ctx.author) + if self.already_playing(ctx.author): + return + + await self._play_game(ctx, user, board_size, str(emoji1), str(emoji2)) + + @guild_only() + @connect_four.command(aliases=("bot", "computer", "cpu")) + async def ai( + self, + ctx: commands.Context, + board_size: int = 7, + emoji1: EMOJI_CHECK = "\U0001f535", + emoji2: EMOJI_CHECK = "\U0001f534" + ) -> None: + """Play Connect Four against a computer player.""" + check, emoji = self.check_emojis(emoji1, emoji2) + if not check: + raise commands.EmojiNotFound(emoji) + + check_author_result = await self.check_author(ctx, board_size) + if not check_author_result: + return + + await self._play_game(ctx, None, board_size, str(emoji1), str(emoji2)) + + +def setup(bot: Bot) -> None: + """Load ConnectFour Cog.""" + bot.add_cog(ConnectFour(bot)) diff --git a/bot/exts/fun/duck_game.py b/bot/exts/fun/duck_game.py new file mode 100644 index 00000000..1ef7513f --- /dev/null +++ b/bot/exts/fun/duck_game.py @@ -0,0 +1,336 @@ +import asyncio +import random +import re +from collections import defaultdict +from io import BytesIO +from itertools import product +from pathlib import Path + +import discord +from PIL import Image, ImageDraw, ImageFont +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Colours, MODERATION_ROLES +from bot.utils.decorators import with_role + +DECK = list(product(*[(0, 1, 2)]*4)) + +GAME_DURATION = 180 + +# Scoring +CORRECT_SOLN = 1 +INCORRECT_SOLN = -1 +CORRECT_GOOSE = 2 +INCORRECT_GOOSE = -1 + +# Distribution of minimum acceptable solutions at board generation. +# This is for gameplay reasons, to shift the number of solutions per board up, +# while still making the end of the game unpredictable. +# Note: this is *not* the same as the distribution of number of solutions. + +SOLN_DISTR = 0, 0.05, 0.05, 0.1, 0.15, 0.25, 0.2, 0.15, .05 + +IMAGE_PATH = Path("bot", "resources", "fun", "all_cards.png") +FONT_PATH = Path("bot", "resources", "fun", "LuckiestGuy-Regular.ttf") +HELP_IMAGE_PATH = Path("bot", "resources", "fun", "ducks_help_ex.png") + +ALL_CARDS = Image.open(IMAGE_PATH) +LABEL_FONT = ImageFont.truetype(str(FONT_PATH), size=16) +CARD_WIDTH = 155 +CARD_HEIGHT = 97 + +EMOJI_WRONG = "\u274C" + +ANSWER_REGEX = re.compile(r'^\D*(\d+)\D+(\d+)\D+(\d+)\D*$') + +HELP_TEXT = """ +**Each card has 4 features** +Color, Number, Hat, and Accessory + +**A valid flight** +3 cards where each feature is either all the same or all different + +**Call "GOOSE"** +if you think there are no more flights + +**+1** for each valid flight +**+2** for a correct "GOOSE" call +**-1** for any wrong answer + +The first flight below is invalid: the first card has swords while the other two have no accessory.\ + It would be valid if the first card was empty-handed, or one of the other two had paintbrushes. + +The second flight is valid because there are no 2:1 splits; each feature is either all the same or all different. +""" + + +def assemble_board_image(board: list[tuple[int]], rows: int, columns: int) -> Image: + """Cut and paste images representing the given cards into an image representing the board.""" + new_im = Image.new("RGBA", (CARD_WIDTH*columns, CARD_HEIGHT*rows)) + draw = ImageDraw.Draw(new_im) + for idx, card in enumerate(board): + card_image = get_card_image(card) + row, col = divmod(idx, columns) + top, left = row * CARD_HEIGHT, col * CARD_WIDTH + new_im.paste(card_image, (left, top)) + draw.text( + xy=(left+5, top+5), # magic numbers are buffers for the card labels + text=str(idx), + fill=(0, 0, 0), + font=LABEL_FONT, + ) + return new_im + + +def get_card_image(card: tuple[int]) -> Image: + """Slice the image containing all the cards to get just this card.""" + # The master card image file should have 9x9 cards, + # arranged such that their features can be interpreted as ordered trinary. + row, col = divmod(as_trinary(card), 9) + x1 = col * CARD_WIDTH + x2 = x1 + CARD_WIDTH + y1 = row * CARD_HEIGHT + y2 = y1 + CARD_HEIGHT + return ALL_CARDS.crop((x1, y1, x2, y2)) + + +def as_trinary(card: tuple[int]) -> int: + """Find the card's unique index by interpreting its features as trinary.""" + return int(''.join(str(x) for x in card), base=3) + + +class DuckGame: + """A class for a single game.""" + + def __init__( + self, + rows: int = 4, + columns: int = 3, + minimum_solutions: int = 1, + ): + """ + Take samples from the deck to generate a board. + + Args: + rows (int, optional): Rows in the game board. Defaults to 4. + columns (int, optional): Columns in the game board. Defaults to 3. + minimum_solutions (int, optional): Minimum acceptable number of solutions in the board. Defaults to 1. + """ + self.rows = rows + self.columns = columns + size = rows * columns + + self._solutions = None + self.claimed_answers = {} + self.scores = defaultdict(int) + self.editing_embed = asyncio.Lock() + + self.board = random.sample(DECK, size) + while len(self.solutions) < minimum_solutions: + self.board = random.sample(DECK, size) + + @property + def board(self) -> list[tuple[int]]: + """Accesses board property.""" + return self._board + + @board.setter + def board(self, val: list[tuple[int]]) -> None: + """Erases calculated solutions if the board changes.""" + self._solutions = None + self._board = val + + @property + def solutions(self) -> None: + """Calculate valid solutions and cache to avoid redoing work.""" + if self._solutions is None: + self._solutions = set() + for idx_a, card_a in enumerate(self.board): + for idx_b, card_b in enumerate(self.board[idx_a+1:], start=idx_a+1): + # Two points determine a line, and there are exactly 3 points per line in {0,1,2}^4. + # The completion of a line will only be a duplicate point if the other two points are the same, + # which is prevented by the triangle iteration. + completion = tuple( + feat_a if feat_a == feat_b else 3-feat_a-feat_b + for feat_a, feat_b in zip(card_a, card_b) + ) + try: + idx_c = self.board.index(completion) + except ValueError: + continue + + # Indices within the solution are sorted to detect duplicate solutions modulo order. + solution = tuple(sorted((idx_a, idx_b, idx_c))) + self._solutions.add(solution) + + return self._solutions + + +class DuckGamesDirector(commands.Cog): + """A cog for running Duck Duck Duck Goose games.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.current_games = {} + + @commands.group( + name='duckduckduckgoose', + aliases=['dddg', 'ddg', 'duckduckgoose', 'duckgoose'], + invoke_without_command=True + ) + @commands.cooldown(rate=1, per=2, type=commands.BucketType.channel) + async def start_game(self, ctx: commands.Context) -> None: + """Generate a board, send the game embed, and end the game after a time limit.""" + if ctx.channel.id in self.current_games: + await ctx.send("There's already a game running!") + return + + minimum_solutions, = random.choices(range(len(SOLN_DISTR)), weights=SOLN_DISTR) + game = DuckGame(minimum_solutions=minimum_solutions) + game.running = True + self.current_games[ctx.channel.id] = game + + game.msg_content = "" + game.embed_msg = await self.send_board_embed(ctx, game) + await asyncio.sleep(GAME_DURATION) + + # Checking for the channel ID in the currently running games is not sufficient. + # The game could have been ended by a player, and a new game already started in the same channel. + if game.running: + try: + del self.current_games[ctx.channel.id] + await self.end_game(ctx.channel, game, end_message="Time's up!") + except KeyError: + pass + + @commands.Cog.listener() + async def on_message(self, msg: discord.Message) -> None: + """Listen for messages and process them as answers if appropriate.""" + if msg.author.bot: + return + + channel = msg.channel + if channel.id not in self.current_games: + return + + game = self.current_games[channel.id] + if msg.content.strip().lower() == 'goose': + # If all of the solutions have been claimed, i.e. the "goose" call is correct. + if len(game.solutions) == len(game.claimed_answers): + try: + del self.current_games[channel.id] + game.scores[msg.author] += CORRECT_GOOSE + await self.end_game(channel, game, end_message=f"{msg.author.display_name} GOOSED!") + except KeyError: + pass + else: + await msg.add_reaction(EMOJI_WRONG) + game.scores[msg.author] += INCORRECT_GOOSE + return + + # Valid answers contain 3 numbers. + if not (match := re.match(ANSWER_REGEX, msg.content)): + return + answer = tuple(sorted(int(m) for m in match.groups())) + + # Be forgiving for answers that use indices not on the board. + if not all(0 <= n < len(game.board) for n in answer): + return + + # Also be forgiving for answers that have already been claimed (and avoid penalizing for racing conditions). + if answer in game.claimed_answers: + return + + if answer in game.solutions: + game.claimed_answers[answer] = msg.author + game.scores[msg.author] += CORRECT_SOLN + await self.display_claimed_answer(game, msg.author, answer) + else: + await msg.add_reaction(EMOJI_WRONG) + game.scores[msg.author] += INCORRECT_SOLN + + async def send_board_embed(self, ctx: commands.Context, game: DuckGame) -> discord.Message: + """Create and send the initial game embed. This will be edited as the game goes on.""" + image = assemble_board_image(game.board, game.rows, game.columns) + with BytesIO() as image_stream: + image.save(image_stream, format="png") + image_stream.seek(0) + file = discord.File(fp=image_stream, filename="board.png") + embed = discord.Embed( + title="Duck Duck Duck Goose!", + color=Colours.bright_green, + ) + embed.set_image(url="attachment://board.png") + return await ctx.send(embed=embed, file=file) + + async def display_claimed_answer(self, game: DuckGame, author: discord.Member, answer: tuple[int]) -> None: + """Add a claimed answer to the game embed.""" + async with game.editing_embed: + # We specifically edit the message contents instead of the embed + # Because we load in the image from the file, editing any portion of the embed + # Does weird things to the image and this works around that weirdness + game.msg_content = f"{game.msg_content}\n{str(answer):12s} - {author.display_name}" + await game.embed_msg.edit(content=game.msg_content) + + async def end_game(self, channel: discord.TextChannel, game: DuckGame, end_message: str) -> None: + """Edit the game embed to reflect the end of the game and mark the game as not running.""" + game.running = False + + scoreboard_embed = discord.Embed( + title=end_message, + color=discord.Color.dark_purple(), + ) + scores = sorted( + game.scores.items(), + key=lambda item: item[1], + reverse=True, + ) + scoreboard = "Final scores:\n\n" + scoreboard += "\n".join(f"{member.display_name}: {score}" for member, score in scores) + scoreboard_embed.description = scoreboard + await channel.send(embed=scoreboard_embed) + + missed = [ans for ans in game.solutions if ans not in game.claimed_answers] + if missed: + missed_text = "Flights everyone missed:\n" + "\n".join(f"{ans}" for ans in missed) + else: + missed_text = "All the flights were found!" + + await game.embed_msg.edit(content=f"{missed_text}") + + @start_game.command(name="help") + async def show_rules(self, ctx: commands.Context) -> None: + """Explain the rules of the game.""" + await self.send_help_embed(ctx) + + @start_game.command(name="stop") + @with_role(*MODERATION_ROLES) + async def stop_game(self, ctx: commands.Context) -> None: + """Stop a currently running game. Only available to mods.""" + try: + game = self.current_games.pop(ctx.channel.id) + except KeyError: + await ctx.send("No game currently running in this channel") + return + await self.end_game(ctx.channel, game, end_message="Game canceled.") + + @staticmethod + async def send_help_embed(ctx: commands.Context) -> discord.Message: + """Send rules embed.""" + embed = discord.Embed( + title="Compete against other players to find valid flights!", + color=discord.Color.dark_purple(), + ) + embed.description = HELP_TEXT + file = discord.File(HELP_IMAGE_PATH, filename="help.png") + embed.set_image(url="attachment://help.png") + embed.set_footer( + text="Tip: using Discord's compact message display mode can help keep the board on the screen" + ) + return await ctx.send(file=file, embed=embed) + + +def setup(bot: Bot) -> None: + """Load the DuckGamesDirector cog.""" + bot.add_cog(DuckGamesDirector(bot)) diff --git a/bot/exts/fun/fun.py b/bot/exts/fun/fun.py new file mode 100644 index 00000000..b148f1f3 --- /dev/null +++ b/bot/exts/fun/fun.py @@ -0,0 +1,250 @@ +import functools +import json +import logging +import random +from collections.abc import Iterable +from pathlib import Path +from typing import Callable, Optional, Union + +from discord import Embed, Message +from discord.ext import commands +from discord.ext.commands import BadArgument, Cog, Context, MessageConverter, clean_content + +from bot import utils +from bot.bot import Bot +from bot.constants import Client, Colours, Emojis +from bot.utils import helpers + +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", +} + + +def caesar_cipher(text: str, offset: int) -> Iterable[str]: + """ + Implements a lazy Caesar Cipher algorithm. + + Encrypts a `text` given a specific integer `offset`. The sign + of the `offset` dictates the direction in which it shifts to, + with a negative value shifting to the left, and a positive + value shifting to the right. + """ + for char in text: + if not char.isascii() or not char.isalpha() or char.isspace(): + yield char + continue + + case_start = 65 if char.isupper() else 97 + true_offset = (ord(char) - case_start + offset) % 26 + + yield chr(case_start + true_offset) + + +class Fun(Cog): + """A collection of general commands for fun.""" + + def __init__(self, bot: Bot): + self.bot = bot + + self._caesar_cipher_embed = json.loads(Path("bot/resources/fun/caesar_info.json").read_text("UTF-8")) + + @staticmethod + def _get_random_die() -> str: + """Generate a random die emoji, ready to be sent on Discord.""" + die_name = f"dice_{random.randint(1, 6)}" + return getattr(Emojis, die_name) + + @commands.command() + async def roll(self, ctx: Context, num_rolls: int = 1) -> None: + """Outputs a number of random dice emotes (up to 6).""" + if 1 <= num_rolls <= 6: + dice = " ".join(self._get_random_die() for _ in range(num_rolls)) + await ctx.send(dice) + else: + raise BadArgument(f"`{Client.prefix}roll` only supports between 1 and 6 rolls.") + + @commands.command(name="uwu", aliases=("uwuwize", "uwuify",)) + async def uwu_command(self, ctx: Context, *, text: clean_content(fix_channel_mentions=True)) -> None: + """Converts a given `text` into it's uwu equivalent.""" + 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) + converted_text = helpers.suppress_links(converted_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: clean_content(fix_channel_mentions=True)) -> None: + """Randomly converts the casing of a given `text`.""" + 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) + converted_text = helpers.suppress_links(converted_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.group(name="caesarcipher", aliases=("caesar", "cc",)) + async def caesarcipher_group(self, ctx: Context) -> None: + """ + Translates a message using the Caesar Cipher. + + See `decrypt`, `encrypt`, and `info` subcommands. + """ + if ctx.invoked_subcommand is None: + await ctx.invoke(self.bot.get_command("help"), "caesarcipher") + + @caesarcipher_group.command(name="info") + async def caesarcipher_info(self, ctx: Context) -> None: + """Information about the Caesar Cipher.""" + embed = Embed.from_dict(self._caesar_cipher_embed) + embed.colour = Colours.dark_green + + await ctx.send(embed=embed) + + @staticmethod + async def _caesar_cipher(ctx: Context, offset: int, msg: str, left_shift: bool = False) -> None: + """ + Given a positive integer `offset`, translates and sends the given `msg`. + + Performs a right shift by default unless `left_shift` is specified as `True`. + + Also accepts a valid Discord Message ID or link. + """ + if offset < 0: + await ctx.send(":no_entry: Cannot use a negative offset.") + return + + if left_shift: + offset = -offset + + def conversion_func(text: str) -> str: + """Encrypts the given string using the Caesar Cipher.""" + return "".join(caesar_cipher(text, offset)) + + text, embed = await Fun._get_text_and_embed(ctx, msg) + + if embed is not None: + embed = Fun._convert_embed(conversion_func, embed) + + converted_text = conversion_func(text) + + if converted_text: + converted_text = f">>> {converted_text.lstrip('> ')}" + + await ctx.send(content=converted_text, embed=embed) + + @caesarcipher_group.command(name="encrypt", aliases=("rightshift", "rshift", "enc",)) + async def caesarcipher_encrypt(self, ctx: Context, offset: int, *, msg: str) -> None: + """ + Given a positive integer `offset`, encrypt the given `msg`. + + Performs a right shift of the letters in the message. + + Also accepts a valid Discord Message ID or link. + """ + await self._caesar_cipher(ctx, offset, msg, left_shift=False) + + @caesarcipher_group.command(name="decrypt", aliases=("leftshift", "lshift", "dec",)) + async def caesarcipher_decrypt(self, ctx: Context, offset: int, *, msg: str) -> None: + """ + Given a positive integer `offset`, decrypt the given `msg`. + + Performs a left shift of the letters in the message. + + Also accepts a valid Discord Message ID or link. + """ + await self._caesar_cipher(ctx, offset, msg, left_shift=True) + + @staticmethod + async def _get_text_and_embed(ctx: Context, text: str) -> tuple[str, Optional[Embed]]: + """ + Attempts to extract the text and embed from a possible link to a discord Message. + + Does not retrieve the text and embed from the Message if it is in a channel the user does + not have read permissions in. + + Returns a tuple of: + str: If `text` is a valid discord Message, the contents of the message, else `text`. + Optional[Embed]: The embed if found in the valid Message, else None + """ + embed = None + + msg = await Fun._get_discord_message(ctx, text) + # Ensure the user has read permissions for the channel the message is in + if isinstance(msg, Message): + permissions = msg.channel.permissions_for(ctx.author) + if permissions.read_messages: + text = msg.clean_content + # Take first embed because we can't send multiple embeds + if msg.embeds: + embed = msg.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: Bot) -> None: + """Load the Fun cog.""" + bot.add_cog(Fun(bot)) diff --git a/bot/exts/fun/game.py b/bot/exts/fun/game.py new file mode 100644 index 00000000..f9c150e6 --- /dev/null +++ b/bot/exts/fun/game.py @@ -0,0 +1,485 @@ +import difflib +import logging +import random +import re +from asyncio import sleep +from datetime import datetime as dt, timedelta +from enum import IntEnum +from typing import Any, Optional + +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 Bot +from bot.constants import STAFF_ROLES, Tokens +from bot.utils.decorators import with_role +from bot.utils.extensions import invoke_help_command +from bot.utils.pagination import ImagePaginator, LinePaginator + +# Base URL of IGDB API +BASE_URL = "https://api.igdb.com/v4" + +CLIENT_ID = Tokens.igdb_client_id +CLIENT_SECRET = Tokens.igdb_client_secret + +# The number of seconds before expiry that we attempt to re-fetch a new access token +ACCESS_TOKEN_RENEWAL_WINDOW = 60*60*24*2 + +# URL to request API access token +OAUTH_URL = "https://id.twitch.tv/oauth2/token" + +OAUTH_PARAMS = { + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "grant_type": "client_credentials" +} + +BASE_HEADERS = { + "Client-ID": CLIENT_ID, + "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: Bot): + self.bot = bot + self.http_session: ClientSession = bot.http_session + + self.genres: dict[str, int] = {} + self.headers = BASE_HEADERS + + self.bot.loop.create_task(self.renew_access_token()) + + async def renew_access_token(self) -> None: + """Refeshes V4 access token a number of seconds before expiry. See `ACCESS_TOKEN_RENEWAL_WINDOW`.""" + while True: + async with self.http_session.post(OAUTH_URL, params=OAUTH_PARAMS) as resp: + result = await resp.json() + if resp.status != 200: + # If there is a valid access token continue to use that, + # otherwise unload cog. + if "Authorization" in self.headers: + time_delta = timedelta(seconds=ACCESS_TOKEN_RENEWAL_WINDOW) + logger.error( + "Failed to renew IGDB access token. " + f"Current token will last for {time_delta} " + f"OAuth response message: {result['message']}" + ) + else: + logger.warning( + "Invalid OAuth credentials. Unloading Games cog. " + f"OAuth response message: {result['message']}" + ) + self.bot.remove_cog("Games") + + return + + self.headers["Authorization"] = f"Bearer {result['access_token']}" + + # Attempt to renew before the token expires + next_renewal = result["expires_in"] - ACCESS_TOKEN_RENEWAL_WINDOW + + time_delta = timedelta(seconds=next_renewal) + logger.info(f"Successfully renewed access token. Refreshing again in {time_delta}") + + # This will be true the first time this loop runs. + # Since we now have an access token, its safe to start this task. + if self.genres == {}: + self.refresh_genres_task.start() + await sleep(next_renewal) + + @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.post(f"{BASE_URL}/genres", data=body, headers=self.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: + """ + 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 + - .games + """ + # When user didn't specified genre, send help message + if genre is None: + await invoke_help_command(ctx) + 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.post(url=f"{BASE_URL}/games", data=body, headers=self.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.post(url=f"{BASE_URL}/games", data=body, headers=self.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.post(url=f"{BASE_URL}/companies", data=body, headers=self.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: Bot) -> None: + """Load the Games cog.""" + # Check does IGDB API key exist, if not, log warning and don't load cog + if not Tokens.igdb_client_id: + logger.warning("No IGDB client ID. Not loading Games cog.") + return + if not Tokens.igdb_client_secret: + logger.warning("No IGDB client secret. Not loading Games cog.") + return + bot.add_cog(Games(bot)) diff --git a/bot/exts/fun/magic_8ball.py b/bot/exts/fun/magic_8ball.py new file mode 100644 index 00000000..a7b682ca --- /dev/null +++ b/bot/exts/fun/magic_8ball.py @@ -0,0 +1,30 @@ +import json +import logging +import random +from pathlib import Path + +from discord.ext import commands + +from bot.bot import Bot + +log = logging.getLogger(__name__) + +ANSWERS = json.loads(Path("bot/resources/fun/magic8ball.json").read_text("utf8")) + + +class Magic8ball(commands.Cog): + """A Magic 8ball command to respond to a user's question.""" + + @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(ANSWERS) + await ctx.send(answer) + else: + await ctx.send("Usage: .8ball (minimum length of 3 eg: `will I win?`)") + + +def setup(bot: Bot) -> None: + """Load the Magic8Ball Cog.""" + bot.add_cog(Magic8ball()) diff --git a/bot/exts/fun/minesweeper.py b/bot/exts/fun/minesweeper.py new file mode 100644 index 00000000..a48b5051 --- /dev/null +++ b/bot/exts/fun/minesweeper.py @@ -0,0 +1,270 @@ +import logging +from collections.abc import Iterator +from dataclasses import dataclass +from random import randint, random +from typing import Union + +import discord +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Client +from bot.utils.converters import CoordinateConverter +from bot.utils.exceptions import UserNotPlayingError +from bot.utils.extensions import invoke_help_command + +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__) + + +GameBoard = list[list[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 + + +class Minesweeper(commands.Cog): + """Play a game of Minesweeper.""" + + def __init__(self): + self.games: dict[int, Game] = {} + + @commands.group(name="minesweeper", aliases=("ms",), invoke_without_command=True) + async def minesweeper_group(self, ctx: commands.Context) -> None: + """Commands for Playing Minesweeper.""" + await invoke_help_command(ctx) + + @staticmethod + def get_neighbours(x: int, y: int) -> Iterator[tuple[int, int]]: + """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 + + try: + 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" + ) + except discord.errors.Forbidden: + log.debug(f"{ctx.author.name} ({ctx.author.id}) has disabled DMs from server members.") + await ctx.send(f":x: {ctx.author.mention}, please enable DMs to play minesweeper.") + return + + # Add game to list + board: GameBoard = self.generate_board(bomb_chance) + revealed_board: GameBoard = [["hidden"] * 10 for _ in range(10)] + dm_msg = await ctx.author.send(f"Here's your board!\n{self.format_for_discord(revealed_board)}") + + if ctx.guild: + await ctx.send(f"{ctx.author.mention} is playing Minesweeper.") + chat_msg = await ctx.send(f"Here's their board!\n{self.format_for_discord(revealed_board)}") + else: + chat_msg = None + + 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 their 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.""" + if ctx.author.id not in self.games: + raise UserNotPlayingError + 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.""" + if ctx.author.id not in self.games: + raise UserNotPlayingError + 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.""" + if ctx.author.id not in self.games: + raise UserNotPlayingError + 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: Bot) -> None: + """Load the Minesweeper cog.""" + bot.add_cog(Minesweeper()) diff --git a/bot/exts/fun/movie.py b/bot/exts/fun/movie.py new file mode 100644 index 00000000..a04eeb41 --- /dev/null +++ b/bot/exts/fun/movie.py @@ -0,0 +1,205 @@ +import logging +import random +from enum import Enum +from typing import Any + +from aiohttp import ClientSession +from discord import Embed +from discord.ext.commands import Cog, Context, group + +from bot.bot import Bot +from bot.constants import Tokens +from bot.utils.extensions import invoke_help_command +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.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_data(self.http_session, MovieGenres[genre].value, 1) + except KeyError: + await invoke_help_command(ctx) + return + + # Check if "results" is in result. If not, throw error. + if "results" not in result: + 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_data(self.http_session, MovieGenres[genre].value, page) + if "results" not in movies: + 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_data(self, client: ClientSession, genre_id: str, page: int) -> list[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" + + # Make discover request to TMDB, return result + async with client.get(url, params=params) 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[str, Any]: + """Get Movie by movie ID from TMDB. Return result dictionary.""" + if not isinstance(movie, int): + raise ValueError("Error while fetching movie from TMDB, movie argument must be integer. ") + url = BASE_URL + f"movie/{movie}" + + async with client.get(url, params=MOVIE_PARAMS) 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.""" + embed = Embed(title=f"Random {name} Movies") + embed.set_footer(text="This product uses the TMDb API but is not endorsed or certified by TMDb.") + embed.set_thumbnail(url="https://i.imgur.com/LtFtC8H.png") + return embed + + +def setup(bot: Bot) -> None: + """Load the Movie Cog.""" + bot.add_cog(Movie(bot)) diff --git a/bot/exts/fun/recommend_game.py b/bot/exts/fun/recommend_game.py new file mode 100644 index 00000000..42c9f7c2 --- /dev/null +++ b/bot/exts/fun/recommend_game.py @@ -0,0 +1,51 @@ +import json +import logging +from pathlib import Path +from random import shuffle + +import discord +from discord.ext import commands + +from bot.bot import Bot + +log = logging.getLogger(__name__) +game_recs = [] + +# Populate the list `game_recs` with resource files +for rec_path in Path("bot/resources/fun/game_recs").glob("*.json"): + data = json.loads(rec_path.read_text("utf8")) + game_recs.append(data) +shuffle(game_recs) + + +class RecommendGame(commands.Cog): + """Commands related to recommending games.""" + + def __init__(self, bot: Bot): + 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.display_avatar.url) + embed.set_image(url=game["image"]) + embed.add_field(name=f"Recommendation: {game['title']}\n{game['link']}", value=game["description"]) + + await ctx.send(embed=embed) + + +def setup(bot: Bot) -> None: + """Loads the RecommendGame cog.""" + bot.add_cog(RecommendGame(bot)) diff --git a/bot/exts/fun/rps.py b/bot/exts/fun/rps.py new file mode 100644 index 00000000..c6bbff46 --- /dev/null +++ b/bot/exts/fun/rps.py @@ -0,0 +1,57 @@ +from random import choice + +from discord.ext import commands + +from bot.bot import Bot + +CHOICES = ["rock", "paper", "scissors"] +SHORT_CHOICES = ["r", "p", "s"] + +# Using a dictionary instead of conditions to check for the winner. +WINNER_DICT = { + "r": { + "r": 0, + "p": -1, + "s": 1, + }, + "p": { + "r": 1, + "p": 0, + "s": -1, + }, + "s": { + "r": -1, + "p": 1, + "s": 0, + } +} + + +class RPS(commands.Cog): + """Rock Paper Scissors. The Classic Game!""" + + @commands.command(case_insensitive=True) + async def rps(self, ctx: commands.Context, move: str) -> None: + """Play the classic game of Rock Paper Scissors with your own sir-lancebot!""" + move = move.lower() + player_mention = ctx.author.mention + + if move not in CHOICES and move not in SHORT_CHOICES: + raise commands.BadArgument(f"Invalid move. Please make move from options: {', '.join(CHOICES).upper()}.") + + bot_move = choice(CHOICES) + # value of player_result will be from (-1, 0, 1) as (lost, tied, won). + player_result = WINNER_DICT[move[0]][bot_move[0]] + + if player_result == 0: + message_string = f"{player_mention} You and Sir Lancebot played {bot_move}, it's a tie." + await ctx.send(message_string) + elif player_result == 1: + await ctx.send(f"Sir Lancebot played {bot_move}! {player_mention} won!") + else: + await ctx.send(f"Sir Lancebot played {bot_move}! {player_mention} lost!") + + +def setup(bot: Bot) -> None: + """Load the RPS Cog.""" + bot.add_cog(RPS(bot)) diff --git a/bot/exts/fun/space.py b/bot/exts/fun/space.py new file mode 100644 index 00000000..48ad0f96 --- /dev/null +++ b/bot/exts/fun/space.py @@ -0,0 +1,236 @@ +import logging +import random +from datetime import date, datetime +from typing import Any, Optional +from urllib.parse import urlencode + +from discord import Embed +from discord.ext import tasks +from discord.ext.commands import Cog, Context, group + +from bot.bot import Bot +from bot.constants import Tokens +from bot.utils.converters import DateConverter +from bot.utils.extensions import invoke_help_command + +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" + +APOD_MIN_DATE = date(1995, 6, 16) + + +class Space(Cog): + """Space Cog contains commands, that show images, facts or other information about space.""" + + def __init__(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 invoke_help_command(ctx) + + @space.command(name="apod") + async def apod(self, ctx: Context, date: Optional[str]) -> 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: + apod_date = datetime.strptime(date, "%Y-%m-%d").date() + except ValueError: + await ctx.send(f"Invalid date {date}. Please make sure your date is in format YYYY-MM-DD.") + return + + now = datetime.now().date() + if APOD_MIN_DATE > apod_date or now < apod_date: + await ctx.send(f"Date must be between {APOD_MIN_DATE.isoformat()} and {now.isoformat()} (today).") + return + + params["date"] = apod_date.isoformat() + + 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: + """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: + """Get a random image of the Earth from the 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], + rover: 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: Bot) -> None: + """Load the 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/fun/speedrun.py b/bot/exts/fun/speedrun.py new file mode 100644 index 00000000..c2966ce1 --- /dev/null +++ b/bot/exts/fun/speedrun.py @@ -0,0 +1,26 @@ +import json +import logging +from pathlib import Path +from random import choice + +from discord.ext import commands + +from bot.bot import Bot + +log = logging.getLogger(__name__) + +LINKS = json.loads(Path("bot/resources/fun/speedrun_links.json").read_text("utf8")) + + +class Speedrun(commands.Cog): + """Commands about the video game speedrunning community.""" + + @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: Bot) -> None: + """Load the Speedrun cog.""" + bot.add_cog(Speedrun()) diff --git a/bot/exts/fun/status_codes.py b/bot/exts/fun/status_codes.py new file mode 100644 index 00000000..501cbe0a --- /dev/null +++ b/bot/exts/fun/status_codes.py @@ -0,0 +1,87 @@ +from random import choice + +import discord +from discord.ext import commands + +from bot.bot import Bot + +HTTP_DOG_URL = "https://httpstatusdogs.com/img/{code}.jpg" +HTTP_CAT_URL = "https://http.cat/{code}.jpg" +STATUS_TEMPLATE = "**Status: {code}**" +ERR_404 = "Unable to find status floof for {code}." +ERR_UNKNOWN = "Error attempting to retrieve status floof for {code}." +ERROR_LENGTH_EMBED = discord.Embed( + title="Input status code does not exist", + description="The range of valid status codes is 100 to 599", +) + + +class HTTPStatusCodes(commands.Cog): + """ + Fetch an image depicting HTTP status codes as a dog or a cat. + + If neither animal is selected a cat or dog is chosen randomly for the given status code. + """ + + def __init__(self, bot: Bot): + self.bot = bot + + @commands.group( + name="http_status", + aliases=("status", "httpstatus"), + invoke_without_command=True, + ) + async def http_status_group(self, ctx: commands.Context, code: int) -> None: + """Choose a cat or dog randomly for the given status code.""" + subcmd = choice((self.http_cat, self.http_dog)) + await subcmd(ctx, code) + + @http_status_group.command(name="cat") + async def http_cat(self, ctx: commands.Context, code: int) -> None: + """Send a cat version of the requested HTTP status code.""" + if code in range(100, 600): + await self.build_embed(url=HTTP_CAT_URL.format(code=code), ctx=ctx, code=code) + return + await ctx.send(embed=ERROR_LENGTH_EMBED) + + @http_status_group.command(name="dog") + async def http_dog(self, ctx: commands.Context, code: int) -> None: + """Send a dog version of the requested HTTP status code.""" + if code in range(100, 600): + await self.build_embed(url=HTTP_DOG_URL.format(code=code), ctx=ctx, code=code) + return + await ctx.send(embed=ERROR_LENGTH_EMBED) + + async def build_embed(self, url: str, ctx: commands.Context, code: int) -> None: + """Attempt to build and dispatch embed. Append error message instead if something goes wrong.""" + async with self.bot.http_session.get(url, allow_redirects=False) as response: + if response.status in range(200, 300): + await ctx.send( + embed=discord.Embed( + title=STATUS_TEMPLATE.format(code=code) + ).set_image(url=url) + ) + elif response.status in (302, 404): # dog URL returns 302 instead of 404 + if "dog" in url: + await ctx.send( + embed=discord.Embed( + title=ERR_404.format(code=code) + ).set_image(url="https://httpstatusdogs.com/img/404.jpg") + ) + return + await ctx.send( + embed=discord.Embed( + title=ERR_404.format(code=code) + ).set_image(url="https://http.cat/404.jpg") + ) + else: + await ctx.send( + embed=discord.Embed( + title=STATUS_TEMPLATE.format(code=code) + ).set_footer(text=ERR_UNKNOWN.format(code=code)) + ) + + +def setup(bot: Bot) -> None: + """Load the HTTPStatusCodes cog.""" + bot.add_cog(HTTPStatusCodes(bot)) diff --git a/bot/exts/fun/tic_tac_toe.py b/bot/exts/fun/tic_tac_toe.py new file mode 100644 index 00000000..5c4f8051 --- /dev/null +++ b/bot/exts/fun/tic_tac_toe.py @@ -0,0 +1,335 @@ +import asyncio +import random +from typing import Callable, Optional, Union + +import discord +from discord.ext.commands import Cog, Context, check, group, guild_only + +from bot.bot import Bot +from bot.constants import Emojis +from bot.utils.pagination import LinePaginator + +CONFIRMATION_MESSAGE = ( + "{opponent}, {requester} wants to play Tic-Tac-Toe against you." + f"\nReact to this message with {Emojis.confirmation} to accept or with {Emojis.decline} to decline." +) + + +def check_win(board: dict[int, str]) -> bool: + """Check from board, is any player won game.""" + return any( + ( + # Horizontal + board[1] == board[2] == board[3], + board[4] == board[5] == board[6], + board[7] == board[8] == board[9], + # Vertical + board[1] == board[4] == board[7], + board[2] == board[5] == board[8], + board[3] == board[6] == board[9], + # Diagonal + board[1] == board[5] == board[9], + board[3] == board[5] == board[7], + ) + ) + + +class Player: + """Class that contains information about player and functions that interact with player.""" + + def __init__(self, user: discord.User, ctx: Context, symbol: str): + self.user = user + self.ctx = ctx + self.symbol = symbol + + async def get_move(self, board: dict[int, str], msg: discord.Message) -> tuple[bool, Optional[int]]: + """ + Get move from user. + + Return is timeout reached and position of field what user will fill when timeout don't reach. + """ + def check_for_move(r: discord.Reaction, u: discord.User) -> bool: + """Check does user who reacted is user who we want, message is board and emoji is in board values.""" + return ( + u.id == self.user.id + and msg.id == r.message.id + and r.emoji in board.values() + and r.emoji in Emojis.number_emojis.values() + ) + + try: + react, _ = await self.ctx.bot.wait_for("reaction_add", timeout=30.0, check=check_for_move) + except asyncio.TimeoutError: + return True, None + else: + return False, list(Emojis.number_emojis.keys())[list(Emojis.number_emojis.values()).index(react.emoji)] + + def __str__(self) -> str: + """Return mention of user.""" + return self.user.mention + + +class AI: + """Tic Tac Toe AI class for against computer gaming.""" + + def __init__(self, symbol: str): + self.symbol = symbol + + async def get_move(self, board: dict[int, str], _: discord.Message) -> tuple[bool, int]: + """Get move from AI. AI use Minimax strategy.""" + possible_moves = [i for i, emoji in board.items() if emoji in list(Emojis.number_emojis.values())] + + for symbol in (Emojis.o_square, Emojis.x_square): + for move in possible_moves: + board_copy = board.copy() + board_copy[move] = symbol + if check_win(board_copy): + return False, move + + open_corners = [i for i in possible_moves if i in (1, 3, 7, 9)] + if len(open_corners) > 0: + return False, random.choice(open_corners) + + if 5 in possible_moves: + return False, 5 + + open_edges = [i for i in possible_moves if i in (2, 4, 6, 8)] + return False, random.choice(open_edges) + + def __str__(self) -> str: + """Return `AI` as user name.""" + return "AI" + + +class Game: + """Class that contains information and functions about Tic Tac Toe game.""" + + def __init__(self, players: list[Union[Player, AI]], ctx: Context): + self.players = players + self.ctx = ctx + self.board = { + 1: Emojis.number_emojis[1], + 2: Emojis.number_emojis[2], + 3: Emojis.number_emojis[3], + 4: Emojis.number_emojis[4], + 5: Emojis.number_emojis[5], + 6: Emojis.number_emojis[6], + 7: Emojis.number_emojis[7], + 8: Emojis.number_emojis[8], + 9: Emojis.number_emojis[9] + } + + self.current = self.players[0] + self.next = self.players[1] + + self.winner: Optional[Union[Player, AI]] = None + self.loser: Optional[Union[Player, AI]] = None + self.over = False + self.canceled = False + self.draw = False + + async def get_confirmation(self) -> tuple[bool, Optional[str]]: + """ + Ask does user want to play TicTacToe against requester. First player is always requester. + + This return tuple that have: + - first element boolean (is game accepted?) + - (optional, only when first element is False, otherwise None) reason for declining. + """ + confirm_message = await self.ctx.send( + CONFIRMATION_MESSAGE.format( + opponent=self.players[1].user.mention, + requester=self.players[0].user.mention + ) + ) + await confirm_message.add_reaction(Emojis.confirmation) + await confirm_message.add_reaction(Emojis.decline) + + def confirm_check(reaction: discord.Reaction, user: discord.User) -> bool: + """Check is user who reacted from who this was requested, message is confirmation and emoji is valid.""" + return ( + reaction.emoji in (Emojis.confirmation, Emojis.decline) + and reaction.message.id == confirm_message.id + and user == self.players[1].user + ) + + try: + reaction, user = await self.ctx.bot.wait_for( + "reaction_add", + timeout=60.0, + check=confirm_check + ) + except asyncio.TimeoutError: + self.over = True + self.canceled = True + await confirm_message.delete() + return False, "Running out of time... Cancelled game." + + await confirm_message.delete() + if reaction.emoji == Emojis.confirmation: + return True, None + else: + self.over = True + self.canceled = True + return False, "User declined" + + async def add_reactions(self, msg: discord.Message) -> None: + """Add number emojis to message.""" + for nr in Emojis.number_emojis.values(): + await msg.add_reaction(nr) + + def format_board(self) -> str: + """Get formatted tic-tac-toe board for message.""" + board = list(self.board.values()) + return "\n".join( + (f"{board[line]} {board[line + 1]} {board[line + 2]}" for line in range(0, len(board), 3)) + ) + + async def play(self) -> None: + """Start and handle game.""" + await self.ctx.send("It's time for the game! Let's begin.") + board = await self.ctx.send( + embed=discord.Embed(description=self.format_board()) + ) + await self.add_reactions(board) + + for _ in range(9): + if isinstance(self.current, Player): + announce = await self.ctx.send( + f"{self.current.user.mention}, it's your turn! " + "React with an emoji to take your go." + ) + timeout, pos = await self.current.get_move(self.board, board) + if isinstance(self.current, Player): + await announce.delete() + if timeout: + await self.ctx.send(f"{self.current.user.mention} ran out of time. Canceling game.") + self.over = True + self.canceled = True + return + self.board[pos] = self.current.symbol + await board.edit( + embed=discord.Embed(description=self.format_board()) + ) + await board.clear_reaction(Emojis.number_emojis[pos]) + if check_win(self.board): + self.winner = self.current + self.loser = self.next + await self.ctx.send( + f":tada: {self.current} won this game! :tada:" + ) + await board.clear_reactions() + break + self.current, self.next = self.next, self.current + if not self.winner: + self.draw = True + await self.ctx.send("It's a DRAW!") + self.over = True + + +def is_channel_free() -> Callable: + """Check is channel where command will be invoked free.""" + async def predicate(ctx: Context) -> bool: + return all(game.channel != ctx.channel for game in ctx.cog.games if not game.over) + return check(predicate) + + +def is_requester_free() -> Callable: + """Check is requester not already in any game.""" + async def predicate(ctx: Context) -> bool: + return all( + ctx.author not in (player.user for player in game.players) for game in ctx.cog.games if not game.over + ) + return check(predicate) + + +class TicTacToe(Cog): + """TicTacToe cog contains tic-tac-toe game commands.""" + + def __init__(self): + self.games: list[Game] = [] + + @guild_only() + @is_channel_free() + @is_requester_free() + @group(name="tictactoe", aliases=("ttt", "tic"), invoke_without_command=True) + async def tic_tac_toe(self, ctx: Context, opponent: Optional[discord.User]) -> None: + """Tic Tac Toe game. Play against friends or AI. Use reactions to add your mark to field.""" + if opponent == ctx.author: + await ctx.send("You can't play against yourself.") + return + if opponent is not None and not all( + opponent not in (player.user for player in g.players) for g in ctx.cog.games if not g.over + ): + await ctx.send("Opponent is already in game.") + return + if opponent is None: + game = Game( + [Player(ctx.author, ctx, Emojis.x_square), AI(Emojis.o_square)], + ctx + ) + else: + game = Game( + [Player(ctx.author, ctx, Emojis.x_square), Player(opponent, ctx, Emojis.o_square)], + ctx + ) + self.games.append(game) + if opponent is not None: + if opponent.bot: # check whether the opponent is a bot or not + await ctx.send("You can't play Tic-Tac-Toe with bots!") + return + + confirmed, msg = await game.get_confirmation() + + if not confirmed: + if msg: + await ctx.send(msg) + return + await game.play() + + @tic_tac_toe.group(name="history", aliases=("log",), invoke_without_command=True) + async def tic_tac_toe_logs(self, ctx: Context) -> None: + """Show most recent tic-tac-toe games.""" + if len(self.games) < 1: + await ctx.send("No recent games.") + return + log_games = [] + for i, game in enumerate(self.games): + if game.over and not game.canceled: + if game.draw: + log_games.append( + f"**#{i+1}**: {game.players[0]} vs {game.players[1]} (draw)" + ) + else: + log_games.append( + f"**#{i+1}**: {game.winner} :trophy: vs {game.loser}" + ) + await LinePaginator.paginate( + log_games, + ctx, + discord.Embed(title="Most recent Tic Tac Toe games") + ) + + @tic_tac_toe_logs.command(name="show", aliases=("s",)) + async def show_tic_tac_toe_board(self, ctx: Context, game_id: int) -> None: + """View game board by ID (ID is possible to get by `.tictactoe history`).""" + if len(self.games) < game_id: + await ctx.send("Game don't exist.") + return + game = self.games[game_id - 1] + + if game.draw: + description = f"{game.players[0]} vs {game.players[1]} (draw)\n\n{game.format_board()}" + else: + description = f"{game.winner} :trophy: vs {game.loser}\n\n{game.format_board()}" + + embed = discord.Embed( + title=f"Match #{game_id} Game Board", + description=description, + ) + await ctx.send(embed=embed) + + +def setup(bot: Bot) -> None: + """Load the TicTacToe cog.""" + bot.add_cog(TicTacToe()) diff --git a/bot/exts/fun/trivia_quiz.py b/bot/exts/fun/trivia_quiz.py new file mode 100644 index 00000000..cf9e6cd3 --- /dev/null +++ b/bot/exts/fun/trivia_quiz.py @@ -0,0 +1,593 @@ +import asyncio +import json +import logging +import operator +import random +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Optional + +import discord +from discord.ext import commands +from rapidfuzz import fuzz + +from bot.bot import Bot +from bot.constants import Colours, NEGATIVE_REPLIES, Roles + +logger = logging.getLogger(__name__) + +DEFAULT_QUESTION_LIMIT = 6 +STANDARD_VARIATION_TOLERANCE = 88 +DYNAMICALLY_GEN_VARIATION_TOLERANCE = 97 + +WRONG_ANS_RESPONSE = [ + "No one answered correctly!", + "Better luck next time...", +] + +N_PREFIX_STARTS_AT = 5 +N_PREFIXES = [ + "penta", "hexa", "hepta", "octa", "nona", + "deca", "hendeca", "dodeca", "trideca", "tetradeca", +] + +PLANETS = [ + ("1st", "Mercury"), + ("2nd", "Venus"), + ("3rd", "Earth"), + ("4th", "Mars"), + ("5th", "Jupiter"), + ("6th", "Saturn"), + ("7th", "Uranus"), + ("8th", "Neptune"), +] + +TAXONOMIC_HIERARCHY = [ + "species", "genus", "family", "order", + "class", "phylum", "kingdom", "domain", +] + +UNITS_TO_BASE_UNITS = { + "hertz": ("(unit of frequency)", "s^-1"), + "newton": ("(unit of force)", "m*kg*s^-2"), + "pascal": ("(unit of pressure & stress)", "m^-1*kg*s^-2"), + "joule": ("(unit of energy & quantity of heat)", "m^2*kg*s^-2"), + "watt": ("(unit of power)", "m^2*kg*s^-3"), + "coulomb": ("(unit of electric charge & quantity of electricity)", "s*A"), + "volt": ("(unit of voltage & electromotive force)", "m^2*kg*s^-3*A^-1"), + "farad": ("(unit of capacitance)", "m^-2*kg^-1*s^4*A^2"), + "ohm": ("(unit of electric resistance)", "m^2*kg*s^-3*A^-2"), + "weber": ("(unit of magnetic flux)", "m^2*kg*s^-2*A^-1"), + "tesla": ("(unit of magnetic flux density)", "kg*s^-2*A^-1"), +} + + +@dataclass(frozen=True) +class QuizEntry: + """Dataclass for a quiz entry (a question and a string containing answers separated by commas).""" + + question: str + answer: str + + +def linear_system(q_format: str, a_format: str) -> QuizEntry: + """Generate a system of linear equations with two unknowns.""" + x, y = random.randint(2, 5), random.randint(2, 5) + answer = a_format.format(x, y) + + coeffs = random.sample(range(1, 6), 4) + + question = q_format.format( + coeffs[0], + coeffs[1], + coeffs[0] * x + coeffs[1] * y, + coeffs[2], + coeffs[3], + coeffs[2] * x + coeffs[3] * y, + ) + + return QuizEntry(question, answer) + + +def mod_arith(q_format: str, a_format: str) -> QuizEntry: + """Generate a basic modular arithmetic question.""" + quotient, m, b = random.randint(30, 40), random.randint(10, 20), random.randint(200, 350) + ans = random.randint(0, 9) # max remainder is 9, since the minimum modulus is 10 + a = quotient * m + ans - b + + question = q_format.format(a, b, m) + answer = a_format.format(ans) + + return QuizEntry(question, answer) + + +def ngonal_prism(q_format: str, a_format: str) -> QuizEntry: + """Generate a question regarding vertices on n-gonal prisms.""" + n = random.randint(0, len(N_PREFIXES) - 1) + + question = q_format.format(N_PREFIXES[n]) + answer = a_format.format((n + N_PREFIX_STARTS_AT) * 2) + + return QuizEntry(question, answer) + + +def imag_sqrt(q_format: str, a_format: str) -> QuizEntry: + """Generate a negative square root question.""" + ans_coeff = random.randint(3, 10) + + question = q_format.format(ans_coeff ** 2) + answer = a_format.format(ans_coeff) + + return QuizEntry(question, answer) + + +def binary_calc(q_format: str, a_format: str) -> QuizEntry: + """Generate a binary calculation question.""" + a = random.randint(15, 20) + b = random.randint(10, a) + oper = random.choice( + ( + ("+", operator.add), + ("-", operator.sub), + ("*", operator.mul), + ) + ) + + # if the operator is multiplication, lower the values of the two operands to make it easier + if oper[0] == "*": + a -= 5 + b -= 5 + + question = q_format.format(a, oper[0], b) + answer = a_format.format(oper[1](a, b)) + + return QuizEntry(question, answer) + + +def solar_system(q_format: str, a_format: str) -> QuizEntry: + """Generate a question on the planets of the Solar System.""" + planet = random.choice(PLANETS) + + question = q_format.format(planet[0]) + answer = a_format.format(planet[1]) + + return QuizEntry(question, answer) + + +def taxonomic_rank(q_format: str, a_format: str) -> QuizEntry: + """Generate a question on taxonomic classification.""" + level = random.randint(0, len(TAXONOMIC_HIERARCHY) - 2) + + question = q_format.format(TAXONOMIC_HIERARCHY[level]) + answer = a_format.format(TAXONOMIC_HIERARCHY[level + 1]) + + return QuizEntry(question, answer) + + +def base_units_convert(q_format: str, a_format: str) -> QuizEntry: + """Generate a SI base units conversion question.""" + unit = random.choice(list(UNITS_TO_BASE_UNITS)) + + question = q_format.format( + unit + " " + UNITS_TO_BASE_UNITS[unit][0] + ) + answer = a_format.format( + UNITS_TO_BASE_UNITS[unit][1] + ) + + return QuizEntry(question, answer) + + +DYNAMIC_QUESTIONS_FORMAT_FUNCS = { + 201: linear_system, + 202: mod_arith, + 203: ngonal_prism, + 204: imag_sqrt, + 205: binary_calc, + 301: solar_system, + 302: taxonomic_rank, + 303: base_units_convert, +} + + +class TriviaQuiz(commands.Cog): + """A cog for all quiz commands.""" + + def __init__(self, bot: Bot): + self.bot = bot + + 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.questions = self.load_questions() + self.question_limit = 0 + + 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.", + "math": "General questions about mathematics ranging from grade 8 to grade 12.", + "science": "Put your understanding of science to the test!", + "cs": "A large variety of computer science questions.", + "python": "Trivia on our amazing language, Python!", + } + + @staticmethod + def load_questions() -> dict: + """Load the questions from the JSON file.""" + p = Path("bot", "resources", "fun", "trivia_quiz.json") + + return json.loads(p.read_text(encoding="utf-8")) + + @commands.group(name="quiz", aliases=["trivia"], invoke_without_command=True) + async def quiz_game(self, ctx: commands.Context, category: Optional[str], questions: Optional[int]) -> None: + """ + Start a quiz! + + Questions for the quiz can be selected from the following categories: + - general: Test your general knowledge. + - retro: Questions related to retro gaming. + - math: General questions about mathematics ranging from grade 8 to grade 12. + - science: Put your understanding of science to the test! + - cs: A large variety of computer science questions. + - python: Trivia on our amazing language, Python! + + (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]: + await ctx.send( + "Game is already running... " + f"do `{self.bot.command_prefix}quiz stop`" + ) + return + + # 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 + + topic = self.questions[category] + topic_length = len(topic) + + if questions is None: + self.question_limit = DEFAULT_QUESTION_LIMIT + else: + if questions > topic_length: + await ctx.send( + embed=self.make_error_embed( + f"This category only has {topic_length} questions. " + "Please input a lower value!" + ) + ) + return + + elif questions < 1: + await ctx.send( + embed=self.make_error_embed( + "You must choose to complete at least one question. " + f"(or enter nothing for the default value of {DEFAULT_QUESTION_LIMIT + 1} questions)" + ) + ) + return + + else: + self.question_limit = questions - 1 + + # Start game if not running. + if not self.game_status[ctx.channel.id]: + 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(5) + + done_question = [] + hint_no = 0 + answers = 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 + + if "dynamic_id" not in question_dict: + question = question_dict["question"] + answers = question_dict["answer"].split(", ") + + var_tol = STANDARD_VARIATION_TOLERANCE + else: + format_func = DYNAMIC_QUESTIONS_FORMAT_FUNCS[question_dict["dynamic_id"]] + + quiz_entry = format_func( + question_dict["question"], + question_dict["answer"], + ) + + question, answers = quiz_entry.question, quiz_entry.answer + answers = [answers] + + var_tol = DYNAMICALLY_GEN_VARIATION_TOLERANCE + + embed = discord.Embed( + colour=Colours.gold, + title=f"Question #{len(done_question)}", + description=question, + ) + + if img_url := question_dict.get("img_url"): + embed.set_image(url=img_url) + + await ctx.send(embed=embed) + + def check_func(variation_tolerance: int) -> Callable[[discord.Message], bool]: + def contains_correct_answer(m: discord.Message) -> bool: + return m.channel == ctx.channel and any( + fuzz.ratio(answer.lower(), m.content.lower()) > variation_tolerance + for answer in answers + ) + + return contains_correct_answer + + try: + msg = await self.bot.wait_for("message", check=check_func(var_tol), timeout=10) + except asyncio.TimeoutError: + # In case of TimeoutError and the game has been stopped, then do nothing. + if not self.game_status[ctx.channel.id]: + break + + if hint_no < 2: + hint_no += 1 + + if "hints" in question_dict: + hints = question_dict["hints"] + + await ctx.send(f"**Hint #{hint_no}\n**{hints[hint_no - 1]}") + 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, + answers, + False, + question_dict, + self.question_limit - len(done_question) + 1, + ) + await asyncio.sleep(1) + + hint_no = 0 # Reset the hint counter so that on the next round, it's in the initial state + + 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 + + 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, + answers, + True, + question_dict, + self.question_limit - len(done_question) + 1, + ) + await self.send_score(ctx.channel, self.game_player_scores[ctx.channel.id]) + + await asyncio.sleep(2) + + def make_start_embed(self, category: str) -> discord.Embed: + """Generate a starting/introduction embed for the quiz.""" + start_embed = discord.Embed( + colour=Colours.blue, + title="A quiz game is starting!", + description=( + f"This game consists of {self.question_limit + 1} questions.\n\n" + "**Rules: **\n" + "1. Only enclose your answer in backticks when the question tells you to.\n" + "2. If the question specifies an answer format, follow it or else it won't be accepted.\n" + "3. You have 30s per question. Points for each question reduces by 25 after 10s or after a hint.\n" + "4. No cheating and have fun!\n\n" + f"**Category**: {category}" + ), + ) + + return start_embed + + @staticmethod + def make_error_embed(desc: str) -> discord.Embed: + """Generate an error embed with the given description.""" + error_embed = discord.Embed( + colour=Colours.soft_red, + title=random.choice(NEGATIVE_REPLIES), + description=desc, + ) + + return error_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. + """ + try: + if self.game_status[ctx.channel.id]: + # 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 + ): + self.game_status[ctx.channel.id] = False + del self.game_owners[ctx.channel.id] + self.game_player_scores[ctx.channel.id] = {} + + await ctx.send("Quiz stopped.") + await self.declare_winner(ctx.channel, 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.") + except KeyError: + 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: + """Send the current scores of players in the game channel.""" + if len(player_data) == 0: + await channel.send("No one has made it onto the leaderboard yet.") + return + + embed = discord.Embed( + colour=Colours.blue, + title="Score Board", + description="", + ) + + sorted_dict = sorted(player_data.items(), key=operator.itemgetter(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: + 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: + 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"You 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=Colours.blue, + title="The available question categories are:", + description="", + ) + + embed.set_footer(text="If a category is not chosen, a random one will be selected.") + + for cat, description in self.categories.items(): + embed.description += ( + f"**- {cat.capitalize()}**\n" + f"{description.capitalize()}\n" + ) + + return embed + + @staticmethod + async def send_answer( + channel: discord.TextChannel, + answers: list[str], + answer_is_correct: bool, + question_dict: dict, + q_left: int, + ) -> None: + """Send the correct answer of a question to the game channel.""" + info = question_dict.get("info") + + plurality = " is" if len(answers) == 1 else "s are" + + embed = discord.Embed( + color=Colours.bright_green, + title=( + ("You got it! " if answer_is_correct else "") + + f"The correct answer{plurality} **`{', '.join(answers)}`**\n" + ), + description="", + ) + + if info is not None: + embed.description += f"**Information**\n{info}\n\n" + + embed.description += ( + ("Let's move to the next question." if q_left > 0 else "") + + f"\nRemaining questions: {q_left}" + ) + await channel.send(embed=embed) + + +def setup(bot: Bot) -> None: + """Load the TriviaQuiz cog.""" + bot.add_cog(TriviaQuiz(bot)) diff --git a/bot/exts/fun/wonder_twins.py b/bot/exts/fun/wonder_twins.py new file mode 100644 index 00000000..79d6b6d9 --- /dev/null +++ b/bot/exts/fun/wonder_twins.py @@ -0,0 +1,49 @@ +import random +from pathlib import Path + +import yaml +from discord.ext.commands import Cog, Context, command + +from bot.bot import Bot + + +class WonderTwins(Cog): + """Cog for a Wonder Twins inspired command.""" + + def __init__(self): + with open(Path.cwd() / "bot" / "resources" / "fun" / "wonder_twins.yaml", "r", encoding="utf-8") as f: + info = yaml.load(f, Loader=yaml.FullLoader) + self.water_types = info["water_types"] + self.objects = info["objects"] + self.adjectives = info["adjectives"] + + @staticmethod + def append_onto(phrase: str, insert_word: str) -> str: + """Appends one word onto the end of another phrase in order to format with the proper determiner.""" + if insert_word.endswith("s"): + phrase = phrase.split() + del phrase[0] + phrase = " ".join(phrase) + + insert_word = insert_word.split()[-1] + return " ".join([phrase, insert_word]) + + def format_phrase(self) -> str: + """Creates a transformation phrase from available words.""" + adjective = random.choice((None, random.choice(self.adjectives))) + object_name = random.choice(self.objects) + water_type = random.choice(self.water_types) + + if adjective: + object_name = self.append_onto(adjective, object_name) + return f"{object_name} of {water_type}" + + @command(name="formof", aliases=("wondertwins", "wondertwin", "fo")) + async def form_of(self, ctx: Context) -> None: + """Command to send a Wonder Twins inspired phrase to the user invoking the command.""" + await ctx.send(f"Form of {self.format_phrase()}!") + + +def setup(bot: Bot) -> None: + """Load the WonderTwins cog.""" + bot.add_cog(WonderTwins()) diff --git a/bot/exts/fun/xkcd.py b/bot/exts/fun/xkcd.py new file mode 100644 index 00000000..b56c53d9 --- /dev/null +++ b/bot/exts/fun/xkcd.py @@ -0,0 +1,91 @@ +import logging +import re +from random import randint +from typing import Optional, Union + +from discord import Embed +from discord.ext import tasks +from discord.ext.commands import Cog, Context, command + +from bot.bot import Bot +from bot.constants import Colours + +log = logging.getLogger(__name__) + +COMIC_FORMAT = re.compile(r"latest|[0-9]+") +BASE_URL = "https://xkcd.com" + + +class XKCD(Cog): + """Retrieving XKCD comics.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.latest_comic_info: dict[str, Union[str, int]] = {} + self.get_latest_comic_info.start() + + def cog_unload(self) -> None: + """Cancels refreshing of the task for refreshing the most recent comic info.""" + self.get_latest_comic_info.cancel() + + @tasks.loop(minutes=30) + async def get_latest_comic_info(self) -> None: + """Refreshes latest comic's information ever 30 minutes. Also used for finding a random comic.""" + async with self.bot.http_session.get(f"{BASE_URL}/info.0.json") as resp: + if resp.status == 200: + self.latest_comic_info = await resp.json() + else: + log.debug(f"Failed to get latest XKCD comic information. Status code {resp.status}") + + @command(name="xkcd") + async def fetch_xkcd_comics(self, ctx: Context, comic: Optional[str]) -> None: + """ + Getting an xkcd comic's information along with the image. + + To get a random comic, don't type any number as an argument. To get the latest, type 'latest'. + """ + embed = Embed(title=f"XKCD comic '{comic}'") + + embed.colour = Colours.soft_red + + if comic and (comic := re.match(COMIC_FORMAT, comic)) is None: + embed.description = "Comic parameter should either be an integer or 'latest'." + await ctx.send(embed=embed) + return + + comic = randint(1, self.latest_comic_info["num"]) if comic is None else comic.group(0) + + if comic == "latest": + info = self.latest_comic_info + else: + async with self.bot.http_session.get(f"{BASE_URL}/{comic}/info.0.json") as resp: + if resp.status == 200: + info = await resp.json() + else: + embed.title = f"XKCD comic #{comic}" + embed.description = f"{resp.status}: Could not retrieve xkcd comic #{comic}." + log.debug(f"Retrieving xkcd comic #{comic} failed with status code {resp.status}.") + await ctx.send(embed=embed) + return + + embed.title = f"XKCD comic #{info['num']}" + embed.description = info["alt"] + embed.url = f"{BASE_URL}/{info['num']}" + + if info["img"][-3:] in ("jpg", "png", "gif"): + embed.set_image(url=info["img"]) + date = f"{info['year']}/{info['month']}/{info['day']}" + embed.set_footer(text=f"{date} - #{info['num']}, \'{info['safe_title']}\'") + embed.colour = Colours.soft_green + else: + embed.description = ( + "The selected comic is interactive, and cannot be displayed within an embed.\n" + f"Comic can be viewed [here](https://xkcd.com/{info['num']})." + ) + + await ctx.send(embed=embed) + + +def setup(bot: Bot) -> None: + """Load the XKCD cog.""" + bot.add_cog(XKCD(bot)) diff --git a/bot/resources/evergreen/LuckiestGuy-Regular.ttf b/bot/resources/evergreen/LuckiestGuy-Regular.ttf deleted file mode 100644 index 8c79c875..00000000 Binary files a/bot/resources/evergreen/LuckiestGuy-Regular.ttf and /dev/null differ diff --git a/bot/resources/evergreen/all_cards.png b/bot/resources/evergreen/all_cards.png deleted file mode 100644 index 10ed2eb8..00000000 Binary files a/bot/resources/evergreen/all_cards.png and /dev/null differ diff --git a/bot/resources/evergreen/caesar_info.json b/bot/resources/evergreen/caesar_info.json deleted file mode 100644 index 8229c4f3..00000000 --- a/bot/resources/evergreen/caesar_info.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "title": "Caesar Cipher", - "description": "**Information**\nThe Caesar Cipher, named after the Roman General Julius Caesar, is one of the simplest and most widely known encryption techniques. It is a type of substitution cipher in which each letter in the plaintext is replaced by a letter given a specific position offset in the alphabet, with the letters wrapping around both sides.\n\n**Examples**\n1) `Hello World` <=> `Khoor Zruog` where letters are shifted forwards by `3`.\n2) `Julius Caesar` <=> `Yjaxjh Rpthpg` where letters are shifted backwards by `11`." -} diff --git a/bot/resources/evergreen/ducks_help_ex.png b/bot/resources/evergreen/ducks_help_ex.png deleted file mode 100644 index 01d9c243..00000000 Binary files a/bot/resources/evergreen/ducks_help_ex.png and /dev/null differ diff --git a/bot/resources/evergreen/game_recs/chrono_trigger.json b/bot/resources/evergreen/game_recs/chrono_trigger.json deleted file mode 100644 index 9720b977..00000000 --- a/bot/resources/evergreen/game_recs/chrono_trigger.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "title": "Chrono Trigger", - "description": "One of the best games of all time. A brilliant story involving time-travel with loveable characters. It has a brilliant score by Yasonuri Mitsuda and artwork by Akira Toriyama. With over 20 endings and New Game+, there is a huge amount of replay value here.", - "link": "https://rawg.io/games/chrono-trigger-1995", - "image": "https://vignette.wikia.nocookie.net/chrono/images/2/24/Chrono_Trigger_cover.jpg", - "author": "352635617709916161" -} diff --git a/bot/resources/evergreen/game_recs/digimon_world.json b/bot/resources/evergreen/game_recs/digimon_world.json deleted file mode 100644 index c1cb4f37..00000000 --- a/bot/resources/evergreen/game_recs/digimon_world.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "title": "Digimon World", - "description": "A great mix of town-building and pet-raising set in the Digimon universe. With plenty of Digimon to raise and recruit to the village, this charming game will keep you occupied for a long time.", - "image": "https://www.mobygames.com/images/covers/l/437308-digimon-world-playstation-front-cover.jpg", - "link": "https://rawg.io/games/digimon-world", - "author": "352635617709916161" -} diff --git a/bot/resources/evergreen/game_recs/doom_2.json b/bot/resources/evergreen/game_recs/doom_2.json deleted file mode 100644 index b60cc05f..00000000 --- a/bot/resources/evergreen/game_recs/doom_2.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "title": "Doom II", - "description": "Doom 2 was one of the first FPS games that I truly enjoyed. It offered awesome weapons, terrifying demons to kill, and a great atmosphere to do it in.", - "image": "https://upload.wikimedia.org/wikipedia/en/thumb/2/29/Doom_II_-_Hell_on_Earth_Coverart.png/220px-Doom_II_-_Hell_on_Earth_Coverart.png", - "link": "https://rawg.io/games/doom-ii", - "author": "352635617709916161" -} diff --git a/bot/resources/evergreen/game_recs/skyrim.json b/bot/resources/evergreen/game_recs/skyrim.json deleted file mode 100644 index ad86db31..00000000 --- a/bot/resources/evergreen/game_recs/skyrim.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "title": "Elder Scrolls V: Skyrim", - "description": "The latest mainline Elder Scrolls game offered a fantastic role-playing experience with untethered freedom and a great story. Offering vast mod support, the game has endless customization and replay value.", - "image": "https://upload.wikimedia.org/wikipedia/en/1/15/The_Elder_Scrolls_V_Skyrim_cover.png", - "link": "https://rawg.io/games/the-elder-scrolls-v-skyrim", - "author": "352635617709916161" -} diff --git a/bot/resources/evergreen/html_colours.json b/bot/resources/evergreen/html_colours.json deleted file mode 100644 index 086083d6..00000000 --- a/bot/resources/evergreen/html_colours.json +++ /dev/null @@ -1,150 +0,0 @@ -{ - "aliceblue": "0xf0f8ff", - "antiquewhite": "0xfaebd7", - "aqua": "0x00ffff", - "aquamarine": "0x7fffd4", - "azure": "0xf0ffff", - "beige": "0xf5f5dc", - "bisque": "0xffe4c4", - "black": "0x000000", - "blanchedalmond": "0xffebcd", - "blue": "0x0000ff", - "blueviolet": "0x8a2be2", - "brown": "0xa52a2a", - "burlywood": "0xdeb887", - "cadetblue": "0x5f9ea0", - "chartreuse": "0x7fff00", - "chocolate": "0xd2691e", - "coral": "0xff7f50", - "cornflowerblue": "0x6495ed", - "cornsilk": "0xfff8dc", - "crimson": "0xdc143c", - "cyan": "0x00ffff", - "darkblue": "0x00008b", - "darkcyan": "0x008b8b", - "darkgoldenrod": "0xb8860b", - "darkgray": "0xa9a9a9", - "darkgreen": "0x006400", - "darkgrey": "0xa9a9a9", - "darkkhaki": "0xbdb76b", - "darkmagenta": "0x8b008b", - "darkolivegreen": "0x556b2f", - "darkorange": "0xff8c00", - "darkorchid": "0x9932cc", - "darkred": "0x8b0000", - "darksalmon": "0xe9967a", - "darkseagreen": "0x8fbc8f", - "darkslateblue": "0x483d8b", - "darkslategray": "0x2f4f4f", - "darkslategrey": "0x2f4f4f", - "darkturquoise": "0x00ced1", - "darkviolet": "0x9400d3", - "deeppink": "0xff1493", - "deepskyblue": "0x00bfff", - "dimgray": "0x696969", - "dimgrey": "0x696969", - "dodgerblue": "0x1e90ff", - "firebrick": "0xb22222", - "floralwhite": "0xfffaf0", - "forestgreen": "0x228b22", - "fuchsia": "0xff00ff", - "gainsboro": "0xdcdcdc", - "ghostwhite": "0xf8f8ff", - "goldenrod": "0xdaa520", - "gold": "0xffd700", - "gray": "0x808080", - "green": "0x008000", - "greenyellow": "0xadff2f", - "grey": "0x808080", - "honeydew": "0xf0fff0", - "hotpink": "0xff69b4", - "indianred": "0xcd5c5c", - "indigo": "0x4b0082", - "ivory": "0xfffff0", - "khaki": "0xf0e68c", - "lavenderblush": "0xfff0f5", - "lavender": "0xe6e6fa", - "lawngreen": "0x7cfc00", - "lemonchiffon": "0xfffacd", - "lightblue": "0xadd8e6", - "lightcoral": "0xf08080", - "lightcyan": "0xe0ffff", - "lightgoldenrodyellow": "0xfafad2", - "lightgray": "0xd3d3d3", - "lightgreen": "0x90ee90", - "lightgrey": "0xd3d3d3", - "lightpink": "0xffb6c1", - "lightsalmon": "0xffa07a", - "lightseagreen": "0x20b2aa", - "lightskyblue": "0x87cefa", - "lightslategray": "0x778899", - "lightslategrey": "0x778899", - "lightsteelblue": "0xb0c4de", - "lightyellow": "0xffffe0", - "lime": "0x00ff00", - "limegreen": "0x32cd32", - "linen": "0xfaf0e6", - "magenta": "0xff00ff", - "maroon": "0x800000", - "mediumaquamarine": "0x66cdaa", - "mediumblue": "0x0000cd", - "mediumorchid": "0xba55d3", - "mediumpurple": "0x9370db", - "mediumseagreen": "0x3cb371", - "mediumslateblue": "0x7b68ee", - "mediumspringgreen": "0x00fa9a", - "mediumturquoise": "0x48d1cc", - "mediumvioletred": "0xc71585", - "midnightblue": "0x191970", - "mintcream": "0xf5fffa", - "mistyrose": "0xffe4e1", - "moccasin": "0xffe4b5", - "navajowhite": "0xffdead", - "navy": "0x000080", - "oldlace": "0xfdf5e6", - "olive": "0x808000", - "olivedrab": "0x6b8e23", - "orange": "0xffa500", - "orangered": "0xff4500", - "orchid": "0xda70d6", - "palegoldenrod": "0xeee8aa", - "palegreen": "0x98fb98", - "paleturquoise": "0xafeeee", - "palevioletred": "0xdb7093", - "papayawhip": "0xffefd5", - "peachpuff": "0xffdab9", - "peru": "0xcd853f", - "pink": "0xffc0cb", - "plum": "0xdda0dd", - "powderblue": "0xb0e0e6", - "purple": "0x800080", - "rebeccapurple": "0x663399", - "red": "0xff0000", - "rosybrown": "0xbc8f8f", - "royalblue": "0x4169e1", - "saddlebrown": "0x8b4513", - "salmon": "0xfa8072", - "sandybrown": "0xf4a460", - "seagreen": "0x2e8b57", - "seashell": "0xfff5ee", - "sienna": "0xa0522d", - "silver": "0xc0c0c0", - "skyblue": "0x87ceeb", - "slateblue": "0x6a5acd", - "slategray": "0x708090", - "slategrey": "0x708090", - "snow": "0xfffafa", - "springgreen": "0x00ff7f", - "steelblue": "0x4682b4", - "tan": "0xd2b48c", - "teal": "0x008080", - "thistle": "0xd8bfd8", - "tomato": "0xff6347", - "turquoise": "0x40e0d0", - "violet": "0xee82ee", - "wheat": "0xf5deb3", - "white": "0xffffff", - "whitesmoke": "0xf5f5f5", - "yellow": "0xffff00", - "yellowgreen": "0x9acd32" -} diff --git a/bot/resources/evergreen/magic8ball.json b/bot/resources/evergreen/magic8ball.json deleted file mode 100644 index f5f1df62..00000000 --- a/bot/resources/evergreen/magic8ball.json +++ /dev/null @@ -1,22 +0,0 @@ -[ - "It is certain", - "It is decidedly so", - "Without a doubt", - "Yes definitely", - "You may rely on it", - "As I see it, yes", - "Most likely", - "Outlook good", - "Yes", - "Signs point to yes", - "Reply hazy try again", - "Ask again later", - "Better not tell you now", - "Cannot predict now", - "Concentrate and ask again", - "Don't count on it", - "My reply is no", - "My sources say no", - "Outlook not so good", - "Very doubtful" -] diff --git a/bot/resources/evergreen/speedrun_links.json b/bot/resources/evergreen/speedrun_links.json deleted file mode 100644 index acb5746a..00000000 --- a/bot/resources/evergreen/speedrun_links.json +++ /dev/null @@ -1,18 +0,0 @@ - [ - "https://www.youtube.com/watch?v=jNE28SDXdyQ", - "https://www.youtube.com/watch?v=iI8Giq7zQDk", - "https://www.youtube.com/watch?v=VqNnkqQgFbc", - "https://www.youtube.com/watch?v=Gum4GI2Jr0s", - "https://www.youtube.com/watch?v=5YHjHzHJKkU", - "https://www.youtube.com/watch?v=X0pJSTy4tJI", - "https://www.youtube.com/watch?v=aVFq0H6D6_M", - "https://www.youtube.com/watch?v=1O6LuJbEbSI", - "https://www.youtube.com/watch?v=Bgh30BiWG58", - "https://www.youtube.com/watch?v=wwvgAAvhxM8", - "https://www.youtube.com/watch?v=0TWQr0_fi80", - "https://www.youtube.com/watch?v=hatqZby-0to", - "https://www.youtube.com/watch?v=tmnMq2Hw72w", - "https://www.youtube.com/watch?v=UTkyeTCAucA", - "https://www.youtube.com/watch?v=67kQ3l-1qMs", - "https://www.youtube.com/watch?v=14wqBA5Q1yc" -] diff --git a/bot/resources/evergreen/trivia_quiz.json b/bot/resources/evergreen/trivia_quiz.json deleted file mode 100644 index 8008838c..00000000 --- a/bot/resources/evergreen/trivia_quiz.json +++ /dev/null @@ -1,912 +0,0 @@ -{ - "retro": [ - { - "id": 1, - "hints": [ - "It is not a mainline Mario Game, although the plumber is present.", - "It is not a mainline Zelda Game, although Link is present." - ], - "question": "What is the best selling game on the Nintendo GameCube?", - "answer": "Super Smash Bros" - }, - { - "id": 2, - "hints": [ - "It was released before the 90's.", - "It was released after 1980." - ], - "question": "What year was Tetris released?", - "answer": "1984" - }, - { - "id": 3, - "hints": [ - "The occupation was in construction", - "He appeared as this kind of worker in 1981's Donkey Kong" - ], - "question": "What was Mario's original occupation?", - "answer": "Carpenter" - }, - { - "id": 4, - "hints": [ - "It was revealed in the Nintendo Character Guide in 1993.", - "His last name has to do with eating Mario's enemies." - ], - "question": "What is Yoshi's (from Mario Bros.) full name?", - "answer": "Yoshisaur Munchakoopas" - }, - { - "id": 5, - "hints": [ - "The game was released in 1990.", - "It was released on the SNES." - ], - "question": "What was the first game Yoshi appeared in?", - "answer": "Super Mario World" - }, - { - "id": 6, - "hints": [ - "They were used alternatively to playing cards.", - "They generally have handdrawn nature images on them." - ], - "question": "What did Nintendo make before video games and toys?", - "answer": "Hanafuda, Hanafuda cards" - }, - { - "id": 7, - "hints": [ - "Before being Nintendo's main competitor in home gaming, they were successful in arcades.", - "Their first console was called the Master System." - ], - "question": "Who was Nintendo's biggest competitor in 1990?", - "answer": "Sega" - } - ], - "general": [ - { - "id": 100, - "question": "Name \"the land of a thousand lakes\"", - "answer": "Finland", - "info": "Finland is a country in Northern Europe. Sweden borders it to the northwest, Estonia to the south, Russia to the east, and Norway to the north. Finland is part of the European Union with its capital city being Helsinki. With a population of 5.5 million people, it has over 187,000 lakes. The thousands of lakes in Finland are the reason why the country's nickname is \"the land of a thousand lakes.\"" - }, - { - "id": 101, - "question": "Who was the winner of FIFA 2018?", - "answer": "France", - "info": "France 4 - 2 Croatia" - }, - { - "id": 102, - "question": "What is the largest ocean in the world?", - "answer": "Pacific", - "info": "The Pacific Ocean is the largest and deepest of the world ocean basins. Covering approximately 63 million square miles and containing more than half of the free water on Earth, the Pacific is by far the largest of the world's ocean basins." - }, - { - "id": 103, - "question": "Who gifted the Statue Of Liberty?", - "answer": "France", - "info": "The Statue of Liberty was a gift from the French people commemorating the alliance of France and the United States during the American Revolution. Yet, it represented much more to those individuals who proposed the gift." - }, - { - "id": 104, - "question": "Which country is known as the \"Land Of The Rising Sun\"?", - "answer": "Japan", - "info": "The title stems from the Japanese names for Japan, Nippon/Nihon, both literally translating to \"the suns origin\"." - }, - { - "id": 105, - "question": "What's known as the \"Playground of Europe\"?", - "answer": "Switzerland", - "info": "It comes from the title of a book written in 1870 by Leslie Stephen (father of Virginia Woolf) detailing his exploits of mountain climbing (not skiing) of which sport he was one of the pioneers and trekking or walking." - }, - { - "id": 106, - "question": "Which country is known as the \"Land of Thunderbolt\"?", - "answer": "Bhutan", - "info": "Bhutan is known as the \"Land of Thunder Dragon\" or \"Land of Thunderbolt\" due to the violent and large thunderstorms that whip down through the valleys from the Himalayas. The dragon reference was due to people thinking the sparkling light of thunderbolts was the red fire of a dragon." - }, - { - "id": 107, - "question": "Which country is the largest producer of tea in the world?", - "answer": "China", - "info": "Tea is mainly grown in Asia, Africa, South America, and around the Black and Caspian Seas. The four biggest tea-producing countries today are China, India, Sri Lanka and Kenya. Together they represent 75% of world production." - }, - { - "id": 108, - "question": "Which country is the largest producer of coffee?", - "answer": "Brazil", - "info": "Brazil is the world's largest coffee producer. In 2016, Brazil produced a staggering 2,595,000 metric tons of coffee beans. It is not a new development, as Brazil has been the highest global producer of coffee beans for over 150 years." - }, - { - "id": 109, - "question": "Which country is Mount Etna, one of the most active volcanoes in the world, located?", - "answer": "Italy", - "info": "Mount Etna is the highest volcano in Europe. Towering above the city of Catania on the island of Sicily, it has been growing for about 500,000 years and is in the midst of a series of eruptions that began in 2001." - }, - { - "id": 110, - "question": "Which country is called \"Battleground of Europe?\"", - "answer": "Belgium", - "info": "Belgium has been the \"Battleground of Europe\" since the Roman Empire as it had no natural protection from its larger neighbouring countries. The battles of Oudenaarde, Ramillies, Waterloo, Ypres and Bastogne were all fought on Belgian soil." - }, - { - "id": 111, - "question": "Which is the largest tropical rain forest in the world?", - "answer": "Amazon", - "info": "The Amazon is regarded as vital in the fight against global warming due to its ability to absorb carbon from the air. It's often referred to as the \"lungs of the Earth,\" as more than 20 per cent of the world's oxygen is produced there." - }, - { - "id": 112, - "question": "Which is the largest island in the world?", - "answer": "Greenland", - "info": "Commonly thought to be Australia, but as it's actually a continental landmass, it doesn't get to make it in the list." - }, - { - "id": 113, - "question": "What's the name of the tallest waterfall in the world.", - "answer": "Angel Falls", - "info": "Angel Falls (Salto \u00c1ngel) in Venezuela is the highest waterfall in the world. The falls are 3230 feet in height, with an uninterrupted drop of 2647 feet. Angel Falls is located on a tributary of the Rio Caroni." - }, - { - "id": 114, - "question": "What country is called \"Land of White Elephants\"?", - "answer": "Thailand", - "info": "White elephants were regarded to be holy creatures in ancient Thailand and some other countries. Today, white elephants are still used as a symbol of divine and royal power in the country. Ownership of a white elephant symbolizes wealth, success, royalty, political power, wisdom, and prosperity." - }, - { - "id": 115, - "question": "Which city is in two continents?", - "answer": "Istanbul", - "info": "Istanbul embraces two continents, one arm reaching out to Asia, the other to Europe." - }, - { - "id": 116, - "question": "The Valley Of The Kings is located in which country?", - "answer": "Egypt", - "info": "The Valley of the Kings, also known as the Valley of the Gates of the Kings, is a valley in Egypt where, for a period of nearly 500 years from the 16th to 11th century BC, rock cut tombs were excavated for the pharaohs and powerful nobles of the New Kingdom (the Eighteenth to the Twentieth Dynasties of Ancient Egypt)." - }, - { - "id": 117, - "question": "Diamonds are always nice in Minecraft, but can you name the \"Diamond Capital in the World\"?", - "answer": "Antwerp", - "info": "Antwerp, Belgium is where 60-80% of the world's diamonds are cut and traded, and is known as the \"Diamond Capital of the World.\"" - }, - { - "id": 118, - "question": "Where is the \"International Court Of Justice\" located at?", - "answer": "The Hague", - "info": "" - }, - { - "id": 119, - "question": "In which country is Bali located in?", - "answer": "Indonesia", - "info": "" - }, - { - "id": 120, - "question": "What country is the world's largest coral reef system, the \"Great Barrier Reef\", located in?", - "answer": "Australia", - "info": "The Great Barrier Reef is the world's largest coral reef system composed of over 2,900 individual reefs and 900 islands stretching for over 2,300 kilometres (1,400 mi) over an area of approximately 344,400 square kilometres (133,000 sq mi). The reef is located in the Coral Sea, off the coast of Queensland, Australia." - }, - { - "id": 121, - "question": "When did the First World War start?", - "answer": "1914", - "info": "The first world war began in August 1914. It was directly triggered by the assassination of the Austrian archduke, Franz Ferdinand and his wife, on 28th June 1914 by Bosnian revolutionary, Gavrilo Princip. This event was, however, simply the trigger that set off declarations of war." - }, - { - "id": 122, - "question": "Which is the largest hot desert in the world?", - "answer": "Sahara", - "info": "The Sahara Desert covers 3.6 million square miles. It is almost the same size as the United States or China. There are sand dunes in the Sahara as tall as 590 feet." - }, - { - "id": 123, - "question": "Who lived at 221B, Baker Street, London?", - "answer": "Sherlock Holmes", - "info": "" - }, - { - "id": 124, - "question": "When did the Second World War end?", - "answer": "1945", - "info": "World War 2 ended with the unconditional surrender of the Axis powers. On 8 May 1945, the Allies accepted Germany's surrender, about a week after Adolf Hitler had committed suicide. VE Day \u2013 Victory in Europe celebrates the end of the Second World War on 8 May 1945." - }, - { - "id": 125, - "question": "What is the name of the largest dam in the world?", - "answer": "Three Gorges Dam", - "info": "At 1.4 miles wide (2.3 kilometers) and 630 feet (192 meters) high, Three Gorges Dam is the largest hydroelectric dam in the world, according to International Water Power & Dam Construction magazine. Three Gorges impounds the Yangtze River about 1,000 miles (1,610 km) west of Shanghai." - }, - { - "id": 126, - "question": "Which is the smallest planet in the Solar System?", - "answer": "Mercury", - "info": "Mercury is the smallest planet in our solar system. It's just a little bigger than Earth's moon. It is the closest planet to the sun, but it's actually not the hottest. Venus is hotter." - }, - { - "id": 127, - "question": "What is the smallest country?", - "answer": "Vatican City", - "info": "With an area of 0.17 square miles (0.44 km2) and a population right around 1,000, Vatican City is the smallest country in the world, both in terms of size and population." - }, - { - "id": 128, - "question": "What's the name of the largest bird?", - "answer": "Ostrich", - "info": "The largest living bird, a member of the Struthioniformes, is the ostrich (Struthio camelus), from the plains of Africa and Arabia. A large male ostrich can reach a height of 2.8 metres (9.2 feet) and weigh over 156 kilograms (344 pounds)." - }, - { - "id": 129, - "question": "What does the acronym GPRS stand for?", - "answer": "General Packet Radio Service", - "info": "General Packet Radio Service (GPRS) is a packet-based mobile data service on the global system for mobile communications (GSM) of 3G and 2G cellular communication systems. It is a non-voice, high-speed and useful packet-switching technology intended for GSM networks." - }, - { - "id": 130, - "question": "In what country is the Ebro river located?", - "answer": "Spain", - "info": "The Ebro river is located in Spain. It is 930 kilometers long and it's the second longest river that ends on the Mediterranean Sea." - }, - { - "id": 131, - "question": "What year was the IBM PC model 5150 introduced into the market?", - "answer": "1981", - "info": "The IBM PC was introduced into the market in 1981. It used the Intel 8088, with a clock speed of 4.77 MHz, along with the MDA and CGA as a video card." - }, - { - "id": 132, - "question": "What's the world's largest urban area?", - "answer": "Tokyo", - "info": "Tokyo is the most populated city in the world, with a population of 37 million people. It is located in Japan." - }, - { - "id": 133, - "question": "How many planets are there in the Solar system?", - "answer": "8", - "info": "In the Solar system, there are 8 planets: Mercury, Venus, Earth, Mars, Jupiter, Saturn, Uranus and Neptune. Pluto isn't considered a planet in the Solar System anymore." - }, - { - "id": 134, - "question": "What is the capital of Iraq?", - "answer": "Baghdad", - "info": "Baghdad is the capital of Iraq. It has a population of 7 million people." - }, - { - "id": 135, - "question": "The United Nations headquarters is located at which city?", - "answer": "New York", - "info": "The United Nations is headquartered in New York City in a complex designed by a board of architects led by Wallace Harrison and built by the architectural firm Harrison & Abramovitz. The complex has served as the official headquarters of the United Nations since its completion in 1951." - }, - { - "id": 136, - "question": "At what year did Christopher Columbus discover America?", - "answer": "1492", - "info": "The explorer Christopher Columbus made four trips across the Atlantic Ocean from Spain: in 1492, 1493, 1498 and 1502. He was determined to find a direct water route west from Europe to Asia, but he never did. Instead, he stumbled upon the Americas" - } - ], - "math": [ - { - "id": 201, - "question": "What is the highest power of a biquadratic polynomial?", - "answer": "4, four" - }, - { - "id": 202, - "question": "What is the formula for surface area of a sphere?", - "answer": "4pir^2, 4πr^2" - }, - { - "id": 203, - "question": "Which theorem states that hypotenuse^2 = base^2 + height^2?", - "answer": "Pythagorean's, Pythagorean's theorem" - }, - { - "id": 204, - "question": "Which trigonometric function is defined as hypotenuse/opposite?", - "answer": "cosecant, cosec, csc" - }, - { - "id": 205, - "question": "Does the harmonic series converge or diverge?", - "answer": "diverge" - }, - { - "id": 206, - "question": "How many quadrants are there in a cartesian plane?", - "answer": "4, four" - }, - { - "id": 207, - "question": "What is the (0,0) coordinate in a cartesian plane termed as?", - "answer": "origin" - }, - { - "id": 208, - "question": "What's the following formula that finds the area of a triangle called?", - "img_url": "https://wikimedia.org/api/rest_v1/media/math/render/png/d22b8566e8187542966e8d166e72e93746a1a6fc", - "answer": "Heron's formula, Heron" - }, - { - "id": 209, - "dynamic_id": 201, - "question": "Solve the following system of linear equations (format your answer like this & ):\n{}x + {}y = {},\n{}x + {}y = {}", - "answer": "{} & {}" - }, - { - "id": 210, - "dynamic_id": 202, - "question": "What's {} + {} mod {} congruent to?", - "answer": "{}" - }, - { - "id": 211, - "question": "What is the bottom number on a fraction called?", - "answer": "denominator" - }, - { - "id": 212, - "dynamic_id": 203, - "question": "How many vertices are on a {}gonal prism?", - "answer": "{}" - }, - { - "id": 213, - "question": "What is the term used to describe two triangles that have equal corresponding sides and angle measures?", - "answer": "congruent" - }, - { - "id": 214, - "question": "⅓πr^2h is the volume of which 3 dimensional figure?", - "answer": "cone" - }, - { - "id": 215, - "dynamic_id": 204, - "question": "Find the square root of -{}.", - "answer": "{}i" - }, - { - "id": 216, - "question": "In set builder notation, what does {p/q | q ≠ 0, p & q ∈ Z} represent?", - "answer": "Rationals, Rational Numbers" - }, - { - "id": 217, - "question": "What is the natural log of -1 (use i for imaginary number)?", - "answer": "pi*i, pii, πi" - }, - { - "id": 218, - "question": "When is the *inaugural* World Maths Day (format your answer in MM/DD)?", - "answer": "03/13" - }, - { - "id": 219, - "question": "As the Fibonacci sequence extends to infinity, what's the ratio of each number `n` and its preceding number `n-1` approaching?", - "answer": "Golden Ratio" - }, - { - "id": 220, - "question": "0, 1, 1, 2, 3, 5, 8, 13, 21, 34 are numbers of which sequence?", - "answer": "Fibonacci" - }, - { - "id": 221, - "question": "Prime numbers only have __ factors.", - "answer": "2, two" - }, - { - "id": 222, - "question": "In probability, the \\_\\_\\_\\_\\_\\_ \\_\\_\\_\\_\\_ of an experiment or random trial is the set of all possible outcomes of it.", - "answer": "sample space" - }, - { - "id": 223, - "question": "In statistics, what does this formula represent?", - "img_url": "https://www.statisticshowto.com/wp-content/uploads/2013/11/sample-standard-deviation.jpg", - "answer": "sample standard deviation, standard deviation of a sample" - }, - { - "id": 224, - "question": "\"Hexakosioihexekontahexaphobia\" is the fear of which number?", - "answer": "666" - }, - { - "id": 225, - "question": "A matrix multiplied by its inverse matrix equals...", - "answer": "the identity matrix, identity matrix" - }, - { - "id": 226, - "dynamic_id": 205, - "question": "BASE TWO QUESTION: Calculate {:b} {} {:b}", - "answer": "{:b}" - }, - { - "id": 227, - "question": "What is the only number in the entire number system which can be spelled with the same number of letters as itself?", - "answer": "4, four" - - }, - { - "id": 228, - "question": "1/100th of a second is also termed as what?", - "answer": "a jiffy, jiffy, centisecond" - }, - { - "id": 229, - "question": "What is this triangle called?", - "img_url": "https://cdn.askpython.com/wp-content/uploads/2020/07/Pascals-triangle.png", - "answer": "Pascal's triangle, Pascal" - }, - { - "id": 230, - "question": "6a^2 is the surface area of which 3 dimensional figure?", - "answer": "cube" - } - ], - "science": [ - { - "id": 301, - "question": "The three main components of a normal atom are: protons, neutrons, and...", - "answer": "electrons" - }, - { - "id": 302, - "question": "As of 2021, how many elements are there in the Periodic Table?", - "answer": "118" - }, - { - "id": 303, - "question": "What is the universal force discovered by Newton that causes objects with mass to attract each other called?", - "answer": "gravity" - }, - { - "id": 304, - "question": "What do you call an organism composed of only one cell?", - "answer": "unicellular, single-celled" - }, - { - "id": 305, - "question": "The Heisenberg's Uncertainty Principle states that the position and \\_\\_\\_\\_\\_\\_\\_\\_ of a quantum object can't be both exactly measured at the same time.", - "answer": "velocity, momentum" - }, - { - "id": 306, - "question": "A \\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_ reaction is the one wherein an atom or a set of atoms is/are replaced by another atom or a set of atoms", - "answer": "displacement, exchange" - }, - { - "id": 307, - "question": "What is the process by which green plants and certain other organisms transform light energy into chemical energy?", - "answer": "photosynthesis" - }, - { - "id": 308, - "dynamic_id": 301, - "question": "What is the {} planet of our Solar System?", - "answer": "{}" - }, - { - "id": 309, - "dynamic_id": 302, - "question": "In the biological taxonomic hierarchy, what is placed directly above {}?", - "answer": "{}" - }, - { - "id": 310, - "dynamic_id": 303, - "question": "How does one describe the unit {} in SI base units?\n**IMPORTANT:** enclose answer in backticks, use \\* for multiplication, ^ for exponentiation, and place your base units in this order: m - kg - s - A", - "img_url": "https://i.imgur.com/NRzU6tf.png", - "answer": "`{}`" - }, - { - "id": 311, - "question": "How does one call the direct phase transition from gas to solid?", - "answer": "deposition" - }, - { - "id": 312, - "question": "What is the intermolecular force caused by temporary and induced dipoles?", - "answer": "LDF, London dispersion, London dispersion force" - }, - { - "id": 313, - "question": "What is the force that causes objects to float in fluids called?", - "answer": "buoyancy" - }, - { - "id": 314, - "question": "About how many neurons are in the human brain?\n(A. 1 billion, B. 10 billion, C. 100 billion, D. 300 billion)", - "answer": "C, 100 billion, 100 bil" - }, - { - "id": 315, - "question": "What is the name of our galaxy group in which the Milky Way resides?", - "answer": "Local Group" - }, - { - "id": 316, - "question": "Which cell organelle is nicknamed \"the powerhouse of the cell\"?", - "answer": "mitochondria" - }, - { - "id": 317, - "question": "Which vascular tissue transports water and minerals from the roots to the rest of a plant?", - "answer": "the xylem, xylem" - }, - { - "id": 318, - "question": "Who discovered the theories of relativity?", - "answer": "Albert Einstein, Einstein" - }, - { - "id": 319, - "question": "In particle physics, the hypothetical isolated elementary particle with only one magnetic pole is termed as...", - "answer": "magnetic monopole" - }, - { - "id": 320, - "question": "How does one describe a chemical reaction wherein heat is released?", - "answer": "exothermic" - }, - { - "id": 321, - "question": "What range of frequency are the average human ears capable of hearing?\n(A. 10Hz-10kHz, B. 20Hz-20kHz, C. 20Hz-2000Hz, D. 10kHz-20kHz)", - "answer": "B, 20Hz-20kHz" - }, - { - "id": 322, - "question": "What is the process used to separate substances with different polarity in a mixture, using a stationary and mobile phase?", - "answer": "chromatography" - }, - { - "id": 323, - "question": "Which law states that the current through a conductor between two points is directly proportional to the voltage across the two points?", - "answer": "Ohm's law" - }, - { - "id": 324, - "question": "The type of rock that is formed by the accumulation or deposition of mineral or organic particles at the Earth's surface, followed by cementation, is called...", - "answer": "sedimentary, sedimentary rock" - }, - { - "id": 325, - "question": "Is the Richter scale (common earthquake scale) linear or logarithmic?", - "answer": "logarithmic" - }, - { - "id": 326, - "question": "What type of image is formed by a convex mirror?", - "answer": "virtual image, virtual" - }, - { - "id": 327, - "question": "How does one call the branch of physics that deals with the study of mechanical waves in gases, liquids, and solids including topics such as vibration, sound, ultrasound and infrasound", - "answer": "acoustics" - }, - { - "id": 328, - "question": "Which law states that the global entropy in a closed system can only increase?", - "answer": "second law, second law of thermodynamics" - }, - { - "id": 329, - "question": "Which particle is emitted during the beta decay of a radioactive element?", - "answer": "an electron, the electron, electron" - }, - { - "id": 330, - "question": "When DNA is unzipped, two strands are formed. What are they called (separate both answers by the word \"and\")?", - "answer": "leading and lagging, leading strand and lagging strand" - } - ], - "cs": [ - { - "id": 401, - "question": "What does HTML stand for?", - "answer": "HyperText Markup Language" - }, - { - "id": 402, - "question": "What does ASCII stand for?", - "answer": "American Standard Code for Information Interchange" - }, - { - "id": 403, - "question": "What does SASS stand for?", - "answer": "Syntactically Awesome Stylesheets, Syntactically Awesome Style Sheets" - }, - { - "id": 404, - "question": "In neural networks, \\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_ is an algorithm for supervised learning using gradient descent.", - "answer": "backpropagation" - }, - { - "id": 405, - "question": "What is computing capable of performing exaFLOPS called?", - "answer": "exascale computing, exascale" - }, - { - "id": 406, - "question": "In quantum computing, what is the full name of \"qubit\"?", - "answer": "quantum binary digit" - }, - { - "id": 407, - "question": "Given that January 1, 1970 is the starting epoch of time_t in c time, and that time_t is stored as a signed 32-bit integer, when will unix time roll over (year)?", - "answer": "2038" - }, - { - "id": 408, - "question": "What are the components of digital devices that make up logic gates called?", - "answer": "transistors" - }, - { - "id": 409, - "question": "How many possible public IPv6 addresses are there (answer in 2^n)?", - "answer": "2^128" - }, - { - "id": 410, - "question": "A hypothetical point in time at which technological growth becomes uncontrollable and irreversible, resulting in unforeseeable changes to human civilization is termed as...?", - "answer": "technological singularity, singularity" - }, - { - "id": 411, - "question": "In cryptography, the practice of establishing a shared secret between two parties using public keys and private keys is called...?", - "answer": "key exchange" - }, - { - "id": 412, - "question": "How many bits are in a TCP checksum header?", - "answer": "16, sixteen" - }, - { - "id": 413, - "question": "What is the most popular protocol (as of 2021) that handles communication between email servers?", - "answer": "SMTP, Simple Mail Transfer Protocol" - }, - { - "id": 414, - "question": "Which port does SMTP use to communicate between email servers? (assuming its plaintext)", - "answer": "25" - }, - { - "id": 415, - "question": "Which DNS record contains mail servers of a given domain?", - "answer": "MX, mail exchange" - }, - { - "id": 416, - "question": "Which newline sequence does HTTP use?", - "answer": "carriage return line feed, CRLF, \\r\\n" - }, - { - "id": 417, - "question": "What does one call the optimization technique used in CPU design that attempts to guess the outcome of a conditional operation and prepare for the most likely result?", - "answer": "branch prediction" - }, - { - "id": 418, - "question": "Name a universal logic gate.", - "answer": "NAND, NOR" - }, - { - "id": 419, - "question": "What is the mathematical formalism which functional programming was built on?", - "answer": "lambda calculus" - }, - { - "id": 420, - "question": "Why is a DDoS attack different from a DoS attack?\n(A. because the victim's server was indefinitely disrupted from the amount of traffic, B. because it also attacks the victim's confidentiality, C. because the attack had political purposes behind it, D. because the traffic flooding the victim originated from many different sources)", - "answer": "D" - }, - { - "id": 421, - "question": "What is a HTTP/1.1 feature that was superseded by HTTP/2 multiplexing and is unsupported in most browsers nowadays?", - "answer": "pipelining" - }, - { - "id": 422, - "question": "Which of these languages is the oldest?\n(Tcl, Smalltalk 80, Haskell, Standard ML, Java)", - "answer": "Smalltalk 80" - }, - { - "id": 423, - "question": "What is the name for unicode codepoints that do not fit into 16 bits?", - "answer": "surrogates" - }, - { - "id": 424, - "question": "Under what locale does making a string lowercase behave differently?", - "answer": "Turkish" - }, - { - "id": 425, - "question": "What does the \"a\" represent in a HSLA color value?", - "answer": "transparency, translucency, alpha value, alpha channel, alpha" - }, - { - "id": 426, - "question": "What is the section of a GIF that is limited to 256 colors called?", - "answer": "image block" - }, - { - "id": 427, - "question": "What is an interpreter capable of interpreting itself called?", - "answer": "metainterpreter" - }, - { - "id": 428, - "question": "Due to what data storage medium did old programming languages, such as cobol, ignore all characters past the 72nd column?", - "answer": "punch cards" - }, - { - "id": 429, - "question": "Which of these sorting algorithms is not stable?\n(Counting sort, quick sort, insertion sort, tim sort, bubble sort)", - "answer": "quick, quick sort" - }, - { - "id": 430, - "question": "Which of these languages is the youngest?\n(Lisp, Python, Java, Haskell, Prolog, Ruby, Perl)", - "answer": "Java" - } - ], - "python": [ - { - "id": 501, - "question": "Is everything an instance of the `object` class (y/n)?", - "answer": "y, yes" - }, - { - "id": 502, - "question": "Name the only non-dunder method of the builtin slice object.", - "answer": "indices" - }, - { - "id": 503, - "question": "What exception, other than `StopIteration`, can you raise from a `__getitem__` dunder to indicate to an iterator that it should stop?", - "answer": "IndexError" - }, - { - "id": 504, - "question": "What type does the `&` operator return when given 2 `dict_keys` objects?", - "answer": "set" - }, - { - "id": 505, - "question": "Can you pickle a running `list_iterator` (y/n)?", - "answer": "y, yes" - }, - { - "id": 506, - "question": "What attribute of a closure contains the value closed over?", - "answer": "cell_contents" - }, - { - "id": 507, - "question": "What name does a lambda function have?", - "answer": "" - }, - { - "id": 508, - "question": "Which file contains all special site builtins, such as help or credits?", - "answer": "_sitebuiltins" - }, - { - "id": 509, - "question": "Which module when imported opens up a web browser tab that points to the classic 353 XKCD comic mentioning Python?", - "answer": "antigravity" - }, - { - "id": 510, - "question": "Which attribute is the documentation string of a function/method/class stored in (answer should be enclosed in backticks!)?", - "answer": "`__doc__`" - }, - { - "id": 511, - "question": "What is the official name of this operator `:=`, introduced in 3.8?", - "answer": "assignment-expression operator" - }, - { - "id": 512, - "question": "When was Python first released?", - "answer": "1991" - }, - { - "id": 513, - "question": "Where does the name Python come from?", - "answer": "Monty Python, Monty Python's Flying Circus" - }, - { - "id": 514, - "question": "How is infinity represented in Python?", - "answer": "float(\"infinity\"), float('infinity'), float(\"inf\"), float('inf')" - }, - { - "id": 515, - "question": "Which of these characters is valid python outside of string literals in some context?\n(`@`, `$`, `?`)", - "answer": "@" - }, - { - "id": 516, - "question": "Which standard library module is designed for making simple parsers for languages like shell, as well as safe quoting of strings for use in a shell?", - "answer": "shlex" - }, - { - "id": 517, - "question": "Which one of these protocols/abstract base classes does the builtin `range` object NOT implement?\n(`Sequence`, `Iterable`, `Generator`)", - "answer": "Generator" - }, - { - "id": 518, - "question": "What decorator is used to allow a protocol to be checked at runtime?", - "answer": "runtime_checkable, typing.runtime_checkable" - }, - { - "id": 519, - "question": "Does `numbers.Rational` include the builtin object float (y/n)", - "answer": "n, no" - }, - { - "id": 520, - "question": "What is a package that doesn't have a `__init__` file called?", - "answer":"namespace package" - }, - { - "id": 521, - "question": "What file extension is used by the site module to determine what to do at every start?", - "answer": ".pth" - }, - { - "id": 522, - "question": "What is the garbage collection strategy used by cpython to collect everything but reference cycles?", - "answer": "reference counting, refcounting" - }, - { - "id": 523, - "question": "What dunder method is used by the tuple constructor to optimize converting an iterator to a tuple (answer should be enclosed in backticks!)?", - "answer": "`__length_hint__`" - }, - { - "id": 524, - "question": "Which protocol is used to pass self to methods when accessed on classes?", - "answer": "Descriptor" - }, - { - "id": 525, - "question": "Which year was Python 3 released?", - "answer": "2008" - }, - { - "id": 526, - "question": "Which of these is not a generator method?\n(`next`, `send`, `throw`, `close`)", - "answer": "next" - }, - { - "id": 527, - "question": "Is the `__aiter__` method async (y/n)?", - "answer": "n, no" - }, - { - "id": 528, - "question": "How does one call a class who defines the behavior of their instance classes?", - "answer": "a metaclass, metaclass" - }, - { - "id": 529, - "question": "Which of these is a subclass of `Exception`?\n(`NotImplemented`, `asyncio.CancelledError`, `StopIteration`)", - "answer": "StopIteration" - }, - { - "id": 530, - "question": "What type is the attribute of a frame object that contains the current local variables?", - "answer": "dict" - } - ] -} diff --git a/bot/resources/evergreen/wonder_twins.yaml b/bot/resources/evergreen/wonder_twins.yaml deleted file mode 100644 index 05e8d749..00000000 --- a/bot/resources/evergreen/wonder_twins.yaml +++ /dev/null @@ -1,99 +0,0 @@ -water_types: - - ice - - water - - steam - - snow - -objects: - - a bucket - - a spear - - a wall - - a lake - - a ladder - - a boat - - a vial - - a ski slope - - a hand - - a ramp - - clippers - - a bridge - - a dam - - a glacier - - a crowbar - - stilts - - a pole - - a hook - - a wave - - a cage - - a basket - - bolt cutters - - a trapeze - - a puddle - - a toboggan - - a gale - - a cloud - - a unicycle - - a spout - - a sheet - - a gelatin dessert - - a saw - - a geyser - - a jet - - a ball - - handcuffs - - a door - - a row - - a gondola - - a sled - - a rocket - - a swing - - a blizzard - - a saddle - - cubes - - a horse - - a knight - - a rocket pack - - a slick - - a drill - - a shield - - a crane - - a reflector - - a bowling ball - - a turret - - a catapault - - a blanket - - balls - - a faucet - - shears - - a thunder cloud - - a net - - a yoyo - - a block - - a straight-jacket - - a slingshot - - a jack - - a car - - a club - - a vault - - a storm - - a wrench - - an anchor - - a beast - -adjectives: - - a large - - a giant - - a massive - - a small - - a tiny - - a super cool - - a frozen - - a minuscule - - a minute - - a microscopic - - a very small - - a little - - a huge - - an enourmous - - a gigantic - - a great diff --git a/bot/resources/evergreen/xkcd_colours.json b/bot/resources/evergreen/xkcd_colours.json deleted file mode 100644 index 3feeb639..00000000 --- a/bot/resources/evergreen/xkcd_colours.json +++ /dev/null @@ -1,951 +0,0 @@ -{ - "cloudy blue": "0xacc2d9", - "dark pastel green": "0x56ae57", - "dust": "0xb2996e", - "electric lime": "0xa8ff04", - "fresh green": "0x69d84f", - "light eggplant": "0x894585", - "nasty green": "0x70b23f", - "really light blue": "0xd4ffff", - "tea": "0x65ab7c", - "warm purple": "0x952e8f", - "yellowish tan": "0xfcfc81", - "cement": "0xa5a391", - "dark grass green": "0x388004", - "dusty teal": "0x4c9085", - "grey teal": "0x5e9b8a", - "macaroni and cheese": "0xefb435", - "pinkish tan": "0xd99b82", - "spruce": "0x0a5f38", - "strong blue": "0x0c06f7", - "toxic green": "0x61de2a", - "windows blue": "0x3778bf", - "blue blue": "0x2242c7", - "blue with a hint of purple": "0x533cc6", - "booger": "0x9bb53c", - "bright sea green": "0x05ffa6", - "dark green blue": "0x1f6357", - "deep turquoise": "0x017374", - "green teal": "0x0cb577", - "strong pink": "0xff0789", - "bland": "0xafa88b", - "deep aqua": "0x08787f", - "lavender pink": "0xdd85d7", - "light moss green": "0xa6c875", - "light seafoam green": "0xa7ffb5", - "olive yellow": "0xc2b709", - "pig pink": "0xe78ea5", - "deep lilac": "0x966ebd", - "desert": "0xccad60", - "dusty lavender": "0xac86a8", - "purpley grey": "0x947e94", - "purply": "0x983fb2", - "candy pink": "0xff63e9", - "light pastel green": "0xb2fba5", - "boring green": "0x63b365", - "kiwi green": "0x8ee53f", - "light grey green": "0xb7e1a1", - "orange pink": "0xff6f52", - "tea green": "0xbdf8a3", - "very light brown": "0xd3b683", - "egg shell": "0xfffcc4", - "eggplant purple": "0x430541", - "powder pink": "0xffb2d0", - "reddish grey": "0x997570", - "baby shit brown": "0xad900d", - "liliac": "0xc48efd", - "stormy blue": "0x507b9c", - "ugly brown": "0x7d7103", - "custard": "0xfffd78", - "darkish pink": "0xda467d", - "deep brown": "0x410200", - "greenish beige": "0xc9d179", - "manilla": "0xfffa86", - "off blue": "0x5684ae", - "battleship grey": "0x6b7c85", - "browny green": "0x6f6c0a", - "bruise": "0x7e4071", - "kelley green": "0x009337", - "sickly yellow": "0xd0e429", - "sunny yellow": "0xfff917", - "azul": "0x1d5dec", - "darkgreen": "0x054907", - "green/yellow": "0xb5ce08", - "lichen": "0x8fb67b", - "light light green": "0xc8ffb0", - "pale gold": "0xfdde6c", - "sun yellow": "0xffdf22", - "tan green": "0xa9be70", - "burple": "0x6832e3", - "butterscotch": "0xfdb147", - "toupe": "0xc7ac7d", - "dark cream": "0xfff39a", - "indian red": "0x850e04", - "light lavendar": "0xefc0fe", - "poison green": "0x40fd14", - "baby puke green": "0xb6c406", - "bright yellow green": "0x9dff00", - "charcoal grey": "0x3c4142", - "squash": "0xf2ab15", - "cinnamon": "0xac4f06", - "light pea green": "0xc4fe82", - "radioactive green": "0x2cfa1f", - "raw sienna": "0x9a6200", - "baby purple": "0xca9bf7", - "cocoa": "0x875f42", - "light royal blue": "0x3a2efe", - "orangeish": "0xfd8d49", - "rust brown": "0x8b3103", - "sand brown": "0xcba560", - "swamp": "0x698339", - "tealish green": "0x0cdc73", - "burnt siena": "0xb75203", - "camo": "0x7f8f4e", - "dusk blue": "0x26538d", - "fern": "0x63a950", - "old rose": "0xc87f89", - "pale light green": "0xb1fc99", - "peachy pink": "0xff9a8a", - "rosy pink": "0xf6688e", - "light bluish green": "0x76fda8", - "light bright green": "0x53fe5c", - "light neon green": "0x4efd54", - "light seafoam": "0xa0febf", - "tiffany blue": "0x7bf2da", - "washed out green": "0xbcf5a6", - "browny orange": "0xca6b02", - "nice blue": "0x107ab0", - "sapphire": "0x2138ab", - "greyish teal": "0x719f91", - "orangey yellow": "0xfdb915", - "parchment": "0xfefcaf", - "straw": "0xfcf679", - "very dark brown": "0x1d0200", - "terracota": "0xcb6843", - "ugly blue": "0x31668a", - "clear blue": "0x247afd", - "creme": "0xffffb6", - "foam green": "0x90fda9", - "grey/green": "0x86a17d", - "light gold": "0xfddc5c", - "seafoam blue": "0x78d1b6", - "topaz": "0x13bbaf", - "violet pink": "0xfb5ffc", - "wintergreen": "0x20f986", - "yellow tan": "0xffe36e", - "dark fuchsia": "0x9d0759", - "indigo blue": "0x3a18b1", - "light yellowish green": "0xc2ff89", - "pale magenta": "0xd767ad", - "rich purple": "0x720058", - "sunflower yellow": "0xffda03", - "green/blue": "0x01c08d", - "leather": "0xac7434", - "racing green": "0x014600", - "vivid purple": "0x9900fa", - "dark royal blue": "0x02066f", - "hazel": "0x8e7618", - "muted pink": "0xd1768f", - "booger green": "0x96b403", - "canary": "0xfdff63", - "cool grey": "0x95a3a6", - "dark taupe": "0x7f684e", - "darkish purple": "0x751973", - "true green": "0x089404", - "coral pink": "0xff6163", - "dark sage": "0x598556", - "dark slate blue": "0x214761", - "flat blue": "0x3c73a8", - "mushroom": "0xba9e88", - "rich blue": "0x021bf9", - "dirty purple": "0x734a65", - "greenblue": "0x23c48b", - "icky green": "0x8fae22", - "light khaki": "0xe6f2a2", - "warm blue": "0x4b57db", - "dark hot pink": "0xd90166", - "deep sea blue": "0x015482", - "carmine": "0x9d0216", - "dark yellow green": "0x728f02", - "pale peach": "0xffe5ad", - "plum purple": "0x4e0550", - "golden rod": "0xf9bc08", - "neon red": "0xff073a", - "old pink": "0xc77986", - "very pale blue": "0xd6fffe", - "blood orange": "0xfe4b03", - "grapefruit": "0xfd5956", - "sand yellow": "0xfce166", - "clay brown": "0xb2713d", - "dark blue grey": "0x1f3b4d", - "flat green": "0x699d4c", - "light green blue": "0x56fca2", - "warm pink": "0xfb5581", - "dodger blue": "0x3e82fc", - "gross green": "0xa0bf16", - "ice": "0xd6fffa", - "metallic blue": "0x4f738e", - "pale salmon": "0xffb19a", - "sap green": "0x5c8b15", - "algae": "0x54ac68", - "bluey grey": "0x89a0b0", - "greeny grey": "0x7ea07a", - "highlighter green": "0x1bfc06", - "light light blue": "0xcafffb", - "light mint": "0xb6ffbb", - "raw umber": "0xa75e09", - "vivid blue": "0x152eff", - "deep lavender": "0x8d5eb7", - "dull teal": "0x5f9e8f", - "light greenish blue": "0x63f7b4", - "mud green": "0x606602", - "pinky": "0xfc86aa", - "red wine": "0x8c0034", - "shit green": "0x758000", - "tan brown": "0xab7e4c", - "darkblue": "0x030764", - "rosa": "0xfe86a4", - "lipstick": "0xd5174e", - "pale mauve": "0xfed0fc", - "claret": "0x680018", - "dandelion": "0xfedf08", - "orangered": "0xfe420f", - "poop green": "0x6f7c00", - "ruby": "0xca0147", - "dark": "0x1b2431", - "greenish turquoise": "0x00fbb0", - "pastel red": "0xdb5856", - "piss yellow": "0xddd618", - "bright cyan": "0x41fdfe", - "dark coral": "0xcf524e", - "algae green": "0x21c36f", - "darkish red": "0xa90308", - "reddy brown": "0x6e1005", - "blush pink": "0xfe828c", - "camouflage green": "0x4b6113", - "lawn green": "0x4da409", - "putty": "0xbeae8a", - "vibrant blue": "0x0339f8", - "dark sand": "0xa88f59", - "purple/blue": "0x5d21d0", - "saffron": "0xfeb209", - "twilight": "0x4e518b", - "warm brown": "0x964e02", - "bluegrey": "0x85a3b2", - "bubble gum pink": "0xff69af", - "duck egg blue": "0xc3fbf4", - "greenish cyan": "0x2afeb7", - "petrol": "0x005f6a", - "royal": "0x0c1793", - "butter": "0xffff81", - "dusty orange": "0xf0833a", - "off yellow": "0xf1f33f", - "pale olive green": "0xb1d27b", - "orangish": "0xfc824a", - "leaf": "0x71aa34", - "light blue grey": "0xb7c9e2", - "dried blood": "0x4b0101", - "lightish purple": "0xa552e6", - "rusty red": "0xaf2f0d", - "lavender blue": "0x8b88f8", - "light grass green": "0x9af764", - "light mint green": "0xa6fbb2", - "sunflower": "0xffc512", - "velvet": "0x750851", - "brick orange": "0xc14a09", - "lightish red": "0xfe2f4a", - "pure blue": "0x0203e2", - "twilight blue": "0x0a437a", - "violet red": "0xa50055", - "yellowy brown": "0xae8b0c", - "carnation": "0xfd798f", - "muddy yellow": "0xbfac05", - "dark seafoam green": "0x3eaf76", - "deep rose": "0xc74767", - "dusty red": "0xb9484e", - "grey/blue": "0x647d8e", - "lemon lime": "0xbffe28", - "purple/pink": "0xd725de", - "brown yellow": "0xb29705", - "purple brown": "0x673a3f", - "wisteria": "0xa87dc2", - "banana yellow": "0xfafe4b", - "lipstick red": "0xc0022f", - "water blue": "0x0e87cc", - "brown grey": "0x8d8468", - "vibrant purple": "0xad03de", - "baby green": "0x8cff9e", - "barf green": "0x94ac02", - "eggshell blue": "0xc4fff7", - "sandy yellow": "0xfdee73", - "cool green": "0x33b864", - "pale": "0xfff9d0", - "blue/grey": "0x758da3", - "hot magenta": "0xf504c9", - "greyblue": "0x77a1b5", - "purpley": "0x8756e4", - "baby shit green": "0x889717", - "brownish pink": "0xc27e79", - "dark aquamarine": "0x017371", - "diarrhea": "0x9f8303", - "light mustard": "0xf7d560", - "pale sky blue": "0xbdf6fe", - "turtle green": "0x75b84f", - "bright olive": "0x9cbb04", - "dark grey blue": "0x29465b", - "greeny brown": "0x696006", - "lemon green": "0xadf802", - "light periwinkle": "0xc1c6fc", - "seaweed green": "0x35ad6b", - "sunshine yellow": "0xfffd37", - "ugly purple": "0xa442a0", - "medium pink": "0xf36196", - "puke brown": "0x947706", - "very light pink": "0xfff4f2", - "viridian": "0x1e9167", - "bile": "0xb5c306", - "faded yellow": "0xfeff7f", - "very pale green": "0xcffdbc", - "vibrant green": "0x0add08", - "bright lime": "0x87fd05", - "spearmint": "0x1ef876", - "light aquamarine": "0x7bfdc7", - "light sage": "0xbcecac", - "yellowgreen": "0xbbf90f", - "baby poo": "0xab9004", - "dark seafoam": "0x1fb57a", - "deep teal": "0x00555a", - "heather": "0xa484ac", - "rust orange": "0xc45508", - "dirty blue": "0x3f829d", - "fern green": "0x548d44", - "bright lilac": "0xc95efb", - "weird green": "0x3ae57f", - "peacock blue": "0x016795", - "avocado green": "0x87a922", - "faded orange": "0xf0944d", - "grape purple": "0x5d1451", - "hot green": "0x25ff29", - "lime yellow": "0xd0fe1d", - "mango": "0xffa62b", - "shamrock": "0x01b44c", - "bubblegum": "0xff6cb5", - "purplish brown": "0x6b4247", - "vomit yellow": "0xc7c10c", - "pale cyan": "0xb7fffa", - "key lime": "0xaeff6e", - "tomato red": "0xec2d01", - "lightgreen": "0x76ff7b", - "merlot": "0x730039", - "night blue": "0x040348", - "purpleish pink": "0xdf4ec8", - "apple": "0x6ecb3c", - "baby poop green": "0x8f9805", - "green apple": "0x5edc1f", - "heliotrope": "0xd94ff5", - "yellow/green": "0xc8fd3d", - "almost black": "0x070d0d", - "cool blue": "0x4984b8", - "leafy green": "0x51b73b", - "mustard brown": "0xac7e04", - "dusk": "0x4e5481", - "dull brown": "0x876e4b", - "frog green": "0x58bc08", - "vivid green": "0x2fef10", - "bright light green": "0x2dfe54", - "fluro green": "0x0aff02", - "kiwi": "0x9cef43", - "seaweed": "0x18d17b", - "navy green": "0x35530a", - "ultramarine blue": "0x1805db", - "iris": "0x6258c4", - "pastel orange": "0xff964f", - "yellowish orange": "0xffab0f", - "perrywinkle": "0x8f8ce7", - "tealish": "0x24bca8", - "dark plum": "0x3f012c", - "pear": "0xcbf85f", - "pinkish orange": "0xff724c", - "midnight purple": "0x280137", - "light urple": "0xb36ff6", - "dark mint": "0x48c072", - "greenish tan": "0xbccb7a", - "light burgundy": "0xa8415b", - "turquoise blue": "0x06b1c4", - "ugly pink": "0xcd7584", - "sandy": "0xf1da7a", - "electric pink": "0xff0490", - "muted purple": "0x805b87", - "mid green": "0x50a747", - "greyish": "0xa8a495", - "neon yellow": "0xcfff04", - "banana": "0xffff7e", - "carnation pink": "0xff7fa7", - "tomato": "0xef4026", - "sea": "0x3c9992", - "muddy brown": "0x886806", - "turquoise green": "0x04f489", - "buff": "0xfef69e", - "fawn": "0xcfaf7b", - "muted blue": "0x3b719f", - "pale rose": "0xfdc1c5", - "dark mint green": "0x20c073", - "amethyst": "0x9b5fc0", - "blue/green": "0x0f9b8e", - "chestnut": "0x742802", - "sick green": "0x9db92c", - "pea": "0xa4bf20", - "rusty orange": "0xcd5909", - "stone": "0xada587", - "rose red": "0xbe013c", - "pale aqua": "0xb8ffeb", - "deep orange": "0xdc4d01", - "earth": "0xa2653e", - "mossy green": "0x638b27", - "grassy green": "0x419c03", - "pale lime green": "0xb1ff65", - "light grey blue": "0x9dbcd4", - "pale grey": "0xfdfdfe", - "asparagus": "0x77ab56", - "blueberry": "0x464196", - "purple red": "0x990147", - "pale lime": "0xbefd73", - "greenish teal": "0x32bf84", - "caramel": "0xaf6f09", - "deep magenta": "0xa0025c", - "light peach": "0xffd8b1", - "milk chocolate": "0x7f4e1e", - "ocher": "0xbf9b0c", - "off green": "0x6ba353", - "purply pink": "0xf075e6", - "lightblue": "0x7bc8f6", - "dusky blue": "0x475f94", - "golden": "0xf5bf03", - "light beige": "0xfffeb6", - "butter yellow": "0xfffd74", - "dusky purple": "0x895b7b", - "french blue": "0x436bad", - "ugly yellow": "0xd0c101", - "greeny yellow": "0xc6f808", - "orangish red": "0xf43605", - "shamrock green": "0x02c14d", - "orangish brown": "0xb25f03", - "tree green": "0x2a7e19", - "deep violet": "0x490648", - "gunmetal": "0x536267", - "blue/purple": "0x5a06ef", - "cherry": "0xcf0234", - "sandy brown": "0xc4a661", - "warm grey": "0x978a84", - "dark indigo": "0x1f0954", - "midnight": "0x03012d", - "bluey green": "0x2bb179", - "grey pink": "0xc3909b", - "soft purple": "0xa66fb5", - "blood": "0x770001", - "brown red": "0x922b05", - "medium grey": "0x7d7f7c", - "berry": "0x990f4b", - "poo": "0x8f7303", - "purpley pink": "0xc83cb9", - "light salmon": "0xfea993", - "snot": "0xacbb0d", - "easter purple": "0xc071fe", - "light yellow green": "0xccfd7f", - "dark navy blue": "0x00022e", - "drab": "0x828344", - "light rose": "0xffc5cb", - "rouge": "0xab1239", - "purplish red": "0xb0054b", - "slime green": "0x99cc04", - "baby poop": "0x937c00", - "irish green": "0x019529", - "pink/purple": "0xef1de7", - "dark navy": "0x000435", - "greeny blue": "0x42b395", - "light plum": "0x9d5783", - "pinkish grey": "0xc8aca9", - "dirty orange": "0xc87606", - "rust red": "0xaa2704", - "pale lilac": "0xe4cbff", - "orangey red": "0xfa4224", - "primary blue": "0x0804f9", - "kermit green": "0x5cb200", - "brownish purple": "0x76424e", - "murky green": "0x6c7a0e", - "wheat": "0xfbdd7e", - "very dark purple": "0x2a0134", - "bottle green": "0x044a05", - "watermelon": "0xfd4659", - "deep sky blue": "0x0d75f8", - "fire engine red": "0xfe0002", - "yellow ochre": "0xcb9d06", - "pumpkin orange": "0xfb7d07", - "pale olive": "0xb9cc81", - "light lilac": "0xedc8ff", - "lightish green": "0x61e160", - "carolina blue": "0x8ab8fe", - "mulberry": "0x920a4e", - "shocking pink": "0xfe02a2", - "auburn": "0x9a3001", - "bright lime green": "0x65fe08", - "celadon": "0xbefdb7", - "pinkish brown": "0xb17261", - "poo brown": "0x885f01", - "bright sky blue": "0x02ccfe", - "celery": "0xc1fd95", - "dirt brown": "0x836539", - "strawberry": "0xfb2943", - "dark lime": "0x84b701", - "copper": "0xb66325", - "medium brown": "0x7f5112", - "muted green": "0x5fa052", - "robin's egg": "0x6dedfd", - "bright aqua": "0x0bf9ea", - "bright lavender": "0xc760ff", - "ivory": "0xffffcb", - "very light purple": "0xf6cefc", - "light navy": "0x155084", - "pink red": "0xf5054f", - "olive brown": "0x645403", - "poop brown": "0x7a5901", - "mustard green": "0xa8b504", - "ocean green": "0x3d9973", - "very dark blue": "0x000133", - "dusty green": "0x76a973", - "light navy blue": "0x2e5a88", - "minty green": "0x0bf77d", - "adobe": "0xbd6c48", - "barney": "0xac1db8", - "jade green": "0x2baf6a", - "bright light blue": "0x26f7fd", - "light lime": "0xaefd6c", - "dark khaki": "0x9b8f55", - "orange yellow": "0xffad01", - "ocre": "0xc69c04", - "maize": "0xf4d054", - "faded pink": "0xde9dac", - "british racing green": "0x05480d", - "sandstone": "0xc9ae74", - "mud brown": "0x60460f", - "light sea green": "0x98f6b0", - "robin egg blue": "0x8af1fe", - "aqua marine": "0x2ee8bb", - "dark sea green": "0x11875d", - "soft pink": "0xfdb0c0", - "orangey brown": "0xb16002", - "cherry red": "0xf7022a", - "burnt yellow": "0xd5ab09", - "brownish grey": "0x86775f", - "camel": "0xc69f59", - "purplish grey": "0x7a687f", - "marine": "0x042e60", - "greyish pink": "0xc88d94", - "pale turquoise": "0xa5fbd5", - "pastel yellow": "0xfffe71", - "bluey purple": "0x6241c7", - "canary yellow": "0xfffe40", - "faded red": "0xd3494e", - "sepia": "0x985e2b", - "coffee": "0xa6814c", - "bright magenta": "0xff08e8", - "mocha": "0x9d7651", - "ecru": "0xfeffca", - "purpleish": "0x98568d", - "cranberry": "0x9e003a", - "darkish green": "0x287c37", - "brown orange": "0xb96902", - "dusky rose": "0xba6873", - "melon": "0xff7855", - "sickly green": "0x94b21c", - "silver": "0xc5c9c7", - "purply blue": "0x661aee", - "purpleish blue": "0x6140ef", - "hospital green": "0x9be5aa", - "shit brown": "0x7b5804", - "mid blue": "0x276ab3", - "amber": "0xfeb308", - "easter green": "0x8cfd7e", - "soft blue": "0x6488ea", - "cerulean blue": "0x056eee", - "golden brown": "0xb27a01", - "bright turquoise": "0x0ffef9", - "red pink": "0xfa2a55", - "red purple": "0x820747", - "greyish brown": "0x7a6a4f", - "vermillion": "0xf4320c", - "russet": "0xa13905", - "steel grey": "0x6f828a", - "lighter purple": "0xa55af4", - "bright violet": "0xad0afd", - "prussian blue": "0x004577", - "slate green": "0x658d6d", - "dirty pink": "0xca7b80", - "dark blue green": "0x005249", - "pine": "0x2b5d34", - "yellowy green": "0xbff128", - "dark gold": "0xb59410", - "bluish": "0x2976bb", - "darkish blue": "0x014182", - "dull red": "0xbb3f3f", - "pinky red": "0xfc2647", - "bronze": "0xa87900", - "pale teal": "0x82cbb2", - "military green": "0x667c3e", - "barbie pink": "0xfe46a5", - "bubblegum pink": "0xfe83cc", - "pea soup green": "0x94a617", - "dark mustard": "0xa88905", - "shit": "0x7f5f00", - "medium purple": "0x9e43a2", - "very dark green": "0x062e03", - "dirt": "0x8a6e45", - "dusky pink": "0xcc7a8b", - "red violet": "0x9e0168", - "lemon yellow": "0xfdff38", - "pistachio": "0xc0fa8b", - "dull yellow": "0xeedc5b", - "dark lime green": "0x7ebd01", - "denim blue": "0x3b5b92", - "teal blue": "0x01889f", - "lightish blue": "0x3d7afd", - "purpley blue": "0x5f34e7", - "light indigo": "0x6d5acf", - "swamp green": "0x748500", - "brown green": "0x706c11", - "dark maroon": "0x3c0008", - "hot purple": "0xcb00f5", - "dark forest green": "0x002d04", - "faded blue": "0x658cbb", - "drab green": "0x749551", - "light lime green": "0xb9ff66", - "snot green": "0x9dc100", - "yellowish": "0xfaee66", - "light blue green": "0x7efbb3", - "bordeaux": "0x7b002c", - "light mauve": "0xc292a1", - "ocean": "0x017b92", - "marigold": "0xfcc006", - "muddy green": "0x657432", - "dull orange": "0xd8863b", - "steel": "0x738595", - "electric purple": "0xaa23ff", - "fluorescent green": "0x08ff08", - "yellowish brown": "0x9b7a01", - "blush": "0xf29e8e", - "soft green": "0x6fc276", - "bright orange": "0xff5b00", - "lemon": "0xfdff52", - "purple grey": "0x866f85", - "acid green": "0x8ffe09", - "pale lavender": "0xeecffe", - "violet blue": "0x510ac9", - "light forest green": "0x4f9153", - "burnt red": "0x9f2305", - "khaki green": "0x728639", - "cerise": "0xde0c62", - "faded purple": "0x916e99", - "apricot": "0xffb16d", - "dark olive green": "0x3c4d03", - "grey brown": "0x7f7053", - "green grey": "0x77926f", - "true blue": "0x010fcc", - "pale violet": "0xceaefa", - "periwinkle blue": "0x8f99fb", - "light sky blue": "0xc6fcff", - "blurple": "0x5539cc", - "green brown": "0x544e03", - "bluegreen": "0x017a79", - "bright teal": "0x01f9c6", - "brownish yellow": "0xc9b003", - "pea soup": "0x929901", - "forest": "0x0b5509", - "barney purple": "0xa00498", - "ultramarine": "0x2000b1", - "purplish": "0x94568c", - "puke yellow": "0xc2be0e", - "bluish grey": "0x748b97", - "dark periwinkle": "0x665fd1", - "dark lilac": "0x9c6da5", - "reddish": "0xc44240", - "light maroon": "0xa24857", - "dusty purple": "0x825f87", - "terra cotta": "0xc9643b", - "avocado": "0x90b134", - "marine blue": "0x01386a", - "teal green": "0x25a36f", - "slate grey": "0x59656d", - "lighter green": "0x75fd63", - "electric green": "0x21fc0d", - "dusty blue": "0x5a86ad", - "golden yellow": "0xfec615", - "bright yellow": "0xfffd01", - "light lavender": "0xdfc5fe", - "umber": "0xb26400", - "poop": "0x7f5e00", - "dark peach": "0xde7e5d", - "jungle green": "0x048243", - "eggshell": "0xffffd4", - "denim": "0x3b638c", - "yellow brown": "0xb79400", - "dull purple": "0x84597e", - "chocolate brown": "0x411900", - "wine red": "0x7b0323", - "neon blue": "0x04d9ff", - "dirty green": "0x667e2c", - "light tan": "0xfbeeac", - "ice blue": "0xd7fffe", - "cadet blue": "0x4e7496", - "dark mauve": "0x874c62", - "very light blue": "0xd5ffff", - "grey purple": "0x826d8c", - "pastel pink": "0xffbacd", - "very light green": "0xd1ffbd", - "dark sky blue": "0x448ee4", - "evergreen": "0x05472a", - "dull pink": "0xd5869d", - "aubergine": "0x3d0734", - "mahogany": "0x4a0100", - "reddish orange": "0xf8481c", - "deep green": "0x02590f", - "vomit green": "0x89a203", - "purple pink": "0xe03fd8", - "dusty pink": "0xd58a94", - "faded green": "0x7bb274", - "camo green": "0x526525", - "pinky purple": "0xc94cbe", - "pink purple": "0xdb4bda", - "brownish red": "0x9e3623", - "dark rose": "0xb5485d", - "mud": "0x735c12", - "brownish": "0x9c6d57", - "emerald green": "0x028f1e", - "pale brown": "0xb1916e", - "dull blue": "0x49759c", - "burnt umber": "0xa0450e", - "medium green": "0x39ad48", - "clay": "0xb66a50", - "light aqua": "0x8cffdb", - "light olive green": "0xa4be5c", - "brownish orange": "0xcb7723", - "dark aqua": "0x05696b", - "purplish pink": "0xce5dae", - "dark salmon": "0xc85a53", - "greenish grey": "0x96ae8d", - "jade": "0x1fa774", - "ugly green": "0x7a9703", - "dark beige": "0xac9362", - "emerald": "0x01a049", - "pale red": "0xd9544d", - "light magenta": "0xfa5ff7", - "sky": "0x82cafc", - "light cyan": "0xacfffc", - "yellow orange": "0xfcb001", - "reddish purple": "0x910951", - "reddish pink": "0xfe2c54", - "orchid": "0xc875c4", - "dirty yellow": "0xcdc50a", - "orange red": "0xfd411e", - "deep red": "0x9a0200", - "orange brown": "0xbe6400", - "cobalt blue": "0x030aa7", - "neon pink": "0xfe019a", - "rose pink": "0xf7879a", - "greyish purple": "0x887191", - "raspberry": "0xb00149", - "aqua green": "0x12e193", - "salmon pink": "0xfe7b7c", - "tangerine": "0xff9408", - "brownish green": "0x6a6e09", - "red brown": "0x8b2e16", - "greenish brown": "0x696112", - "pumpkin": "0xe17701", - "pine green": "0x0a481e", - "charcoal": "0x343837", - "baby pink": "0xffb7ce", - "cornflower": "0x6a79f7", - "blue violet": "0x5d06e9", - "chocolate": "0x3d1c02", - "greyish green": "0x82a67d", - "scarlet": "0xbe0119", - "green yellow": "0xc9ff27", - "dark olive": "0x373e02", - "sienna": "0xa9561e", - "pastel purple": "0xcaa0ff", - "terracotta": "0xca6641", - "aqua blue": "0x02d8e9", - "sage green": "0x88b378", - "blood red": "0x980002", - "deep pink": "0xcb0162", - "grass": "0x5cac2d", - "moss": "0x769958", - "pastel blue": "0xa2bffe", - "bluish green": "0x10a674", - "green blue": "0x06b48b", - "dark tan": "0xaf884a", - "greenish blue": "0x0b8b87", - "pale orange": "0xffa756", - "vomit": "0xa2a415", - "forrest green": "0x154406", - "dark lavender": "0x856798", - "dark violet": "0x34013f", - "purple blue": "0x632de9", - "dark cyan": "0x0a888a", - "olive drab": "0x6f7632", - "pinkish": "0xd46a7e", - "cobalt": "0x1e488f", - "neon purple": "0xbc13fe", - "light turquoise": "0x7ef4cc", - "apple green": "0x76cd26", - "dull green": "0x74a662", - "wine": "0x80013f", - "powder blue": "0xb1d1fc", - "off white": "0xffffe4", - "electric blue": "0x0652ff", - "dark turquoise": "0x045c5a", - "blue purple": "0x5729ce", - "azure": "0x069af3", - "bright red": "0xff000d", - "pinkish red": "0xf10c45", - "cornflower blue": "0x5170d7", - "light olive": "0xacbf69", - "grape": "0x6c3461", - "greyish blue": "0x5e819d", - "purplish blue": "0x601ef9", - "yellowish green": "0xb0dd16", - "greenish yellow": "0xcdfd02", - "medium blue": "0x2c6fbb", - "dusty rose": "0xc0737a", - "light violet": "0xd6b4fc", - "midnight blue": "0x020035", - "bluish purple": "0x703be7", - "red orange": "0xfd3c06", - "dark magenta": "0x960056", - "greenish": "0x40a368", - "ocean blue": "0x03719c", - "coral": "0xfc5a50", - "cream": "0xffffc2", - "reddish brown": "0x7f2b0a", - "burnt sienna": "0xb04e0f", - "brick": "0xa03623", - "sage": "0x87ae73", - "grey green": "0x789b73", - "white": "0xffffff", - "robin's egg blue": "0x98eff9", - "moss green": "0x658b38", - "steel blue": "0x5a7d9a", - "eggplant": "0x380835", - "light yellow": "0xfffe7a", - "leaf green": "0x5ca904", - "light grey": "0xd8dcd6", - "puke": "0xa5a502", - "pinkish purple": "0xd648d7", - "sea blue": "0x047495", - "pale purple": "0xb790d4", - "slate blue": "0x5b7c99", - "blue grey": "0x607c8e", - "hunter green": "0x0b4008", - "fuchsia": "0xed0dd9", - "crimson": "0x8c000f", - "pale yellow": "0xffff84", - "ochre": "0xbf9005", - "mustard yellow": "0xd2bd0a", - "light red": "0xff474c", - "cerulean": "0x0485d1", - "pale pink": "0xffcfdc", - "deep blue": "0x040273", - "rust": "0xa83c09", - "light teal": "0x90e4c1", - "slate": "0x516572", - "goldenrod": "0xfac205", - "dark yellow": "0xd5b60a", - "dark grey": "0x363737", - "army green": "0x4b5d16", - "grey blue": "0x6b8ba4", - "seafoam": "0x80f9ad", - "puce": "0xa57e52", - "spring green": "0xa9f971", - "dark orange": "0xc65102", - "sand": "0xe2ca76", - "pastel green": "0xb0ff9d", - "mint": "0x9ffeb0", - "light orange": "0xfdaa48", - "bright pink": "0xfe01b1", - "chartreuse": "0xc1f80a", - "deep purple": "0x36013f", - "dark brown": "0x341c02", - "taupe": "0xb9a281", - "pea green": "0x8eab12", - "puke green": "0x9aae07", - "kelly green": "0x02ab2e", - "seafoam green": "0x7af9ab", - "blue green": "0x137e6d", - "khaki": "0xaaa662", - "burgundy": "0x610023", - "dark teal": "0x014d4e", - "brick red": "0x8f1402", - "royal purple": "0x4b006e", - "plum": "0x580f41", - "mint green": "0x8fff9f", - "gold": "0xdbb40c", - "baby blue": "0xa2cffe", - "yellow green": "0xc0fb2d", - "bright purple": "0xbe03fd", - "dark red": "0x840000", - "pale blue": "0xd0fefe", - "grass green": "0x3f9b0b", - "navy": "0x01153e", - "aquamarine": "0x04d8b2", - "burnt orange": "0xc04e01", - "neon green": "0x0cff0c", - "bright blue": "0x0165fc", - "rose": "0xcf6275", - "light pink": "0xffd1df", - "mustard": "0xceb301", - "indigo": "0x380282", - "lime": "0xaaff32", - "sea green": "0x53fca1", - "periwinkle": "0x8e82fe", - "dark pink": "0xcb416b", - "olive green": "0x677a04", - "peach": "0xffb07c", - "pale green": "0xc7fdb5", - "light brown": "0xad8150", - "hot pink": "0xff028d", - "black": "0x000000", - "lilac": "0xcea2fd", - "navy blue": "0x001146", - "royal blue": "0x0504aa", - "beige": "0xe6daa6", - "salmon": "0xff796c", - "olive": "0x6e750e", - "maroon": "0x650021", - "bright green": "0x01ff07", - "dark purple": "0x35063e", - "mauve": "0xae7181", - "forest green": "0x06470c", - "aqua": "0x13eac9", - "cyan": "0x00ffff", - "tan": "0xd1b26f", - "dark blue": "0x00035b", - "lavender": "0xc79fef", - "turquoise": "0x06c2ac", - "dark green": "0x033500", - "violet": "0x9a0eea", - "light purple": "0xbf77f6", - "lime green": "0x89fe05", - "grey": "0x929591", - "sky blue": "0x75bbfd", - "yellow": "0xffff14", - "magenta": "0xc20078", - "light green": "0x96f97b", - "orange": "0xf97306", - "teal": "0x029386", - "light blue": "0x95d0fc", - "red": "0xe50000", - "brown": "0x653700", - "pink": "0xff81c0", - "blue": "0x0343df", - "green": "0x15b01a", - "purple": "0x7e1e9c" -} diff --git a/bot/resources/fun/LuckiestGuy-Regular.ttf b/bot/resources/fun/LuckiestGuy-Regular.ttf new file mode 100644 index 00000000..8c79c875 Binary files /dev/null and b/bot/resources/fun/LuckiestGuy-Regular.ttf differ diff --git a/bot/resources/fun/all_cards.png b/bot/resources/fun/all_cards.png new file mode 100644 index 00000000..10ed2eb8 Binary files /dev/null and b/bot/resources/fun/all_cards.png differ diff --git a/bot/resources/fun/caesar_info.json b/bot/resources/fun/caesar_info.json new file mode 100644 index 00000000..8229c4f3 --- /dev/null +++ b/bot/resources/fun/caesar_info.json @@ -0,0 +1,4 @@ +{ + "title": "Caesar Cipher", + "description": "**Information**\nThe Caesar Cipher, named after the Roman General Julius Caesar, is one of the simplest and most widely known encryption techniques. It is a type of substitution cipher in which each letter in the plaintext is replaced by a letter given a specific position offset in the alphabet, with the letters wrapping around both sides.\n\n**Examples**\n1) `Hello World` <=> `Khoor Zruog` where letters are shifted forwards by `3`.\n2) `Julius Caesar` <=> `Yjaxjh Rpthpg` where letters are shifted backwards by `11`." +} diff --git a/bot/resources/fun/ducks_help_ex.png b/bot/resources/fun/ducks_help_ex.png new file mode 100644 index 00000000..01d9c243 Binary files /dev/null and b/bot/resources/fun/ducks_help_ex.png differ diff --git a/bot/resources/fun/game_recs/chrono_trigger.json b/bot/resources/fun/game_recs/chrono_trigger.json new file mode 100644 index 00000000..9720b977 --- /dev/null +++ b/bot/resources/fun/game_recs/chrono_trigger.json @@ -0,0 +1,7 @@ +{ + "title": "Chrono Trigger", + "description": "One of the best games of all time. A brilliant story involving time-travel with loveable characters. It has a brilliant score by Yasonuri Mitsuda and artwork by Akira Toriyama. With over 20 endings and New Game+, there is a huge amount of replay value here.", + "link": "https://rawg.io/games/chrono-trigger-1995", + "image": "https://vignette.wikia.nocookie.net/chrono/images/2/24/Chrono_Trigger_cover.jpg", + "author": "352635617709916161" +} diff --git a/bot/resources/fun/game_recs/digimon_world.json b/bot/resources/fun/game_recs/digimon_world.json new file mode 100644 index 00000000..c1cb4f37 --- /dev/null +++ b/bot/resources/fun/game_recs/digimon_world.json @@ -0,0 +1,7 @@ +{ + "title": "Digimon World", + "description": "A great mix of town-building and pet-raising set in the Digimon universe. With plenty of Digimon to raise and recruit to the village, this charming game will keep you occupied for a long time.", + "image": "https://www.mobygames.com/images/covers/l/437308-digimon-world-playstation-front-cover.jpg", + "link": "https://rawg.io/games/digimon-world", + "author": "352635617709916161" +} diff --git a/bot/resources/fun/game_recs/doom_2.json b/bot/resources/fun/game_recs/doom_2.json new file mode 100644 index 00000000..b60cc05f --- /dev/null +++ b/bot/resources/fun/game_recs/doom_2.json @@ -0,0 +1,7 @@ +{ + "title": "Doom II", + "description": "Doom 2 was one of the first FPS games that I truly enjoyed. It offered awesome weapons, terrifying demons to kill, and a great atmosphere to do it in.", + "image": "https://upload.wikimedia.org/wikipedia/en/thumb/2/29/Doom_II_-_Hell_on_Earth_Coverart.png/220px-Doom_II_-_Hell_on_Earth_Coverart.png", + "link": "https://rawg.io/games/doom-ii", + "author": "352635617709916161" +} diff --git a/bot/resources/fun/game_recs/skyrim.json b/bot/resources/fun/game_recs/skyrim.json new file mode 100644 index 00000000..ad86db31 --- /dev/null +++ b/bot/resources/fun/game_recs/skyrim.json @@ -0,0 +1,7 @@ +{ + "title": "Elder Scrolls V: Skyrim", + "description": "The latest mainline Elder Scrolls game offered a fantastic role-playing experience with untethered freedom and a great story. Offering vast mod support, the game has endless customization and replay value.", + "image": "https://upload.wikimedia.org/wikipedia/en/1/15/The_Elder_Scrolls_V_Skyrim_cover.png", + "link": "https://rawg.io/games/the-elder-scrolls-v-skyrim", + "author": "352635617709916161" +} diff --git a/bot/resources/fun/html_colours.json b/bot/resources/fun/html_colours.json new file mode 100644 index 00000000..086083d6 --- /dev/null +++ b/bot/resources/fun/html_colours.json @@ -0,0 +1,150 @@ +{ + "aliceblue": "0xf0f8ff", + "antiquewhite": "0xfaebd7", + "aqua": "0x00ffff", + "aquamarine": "0x7fffd4", + "azure": "0xf0ffff", + "beige": "0xf5f5dc", + "bisque": "0xffe4c4", + "black": "0x000000", + "blanchedalmond": "0xffebcd", + "blue": "0x0000ff", + "blueviolet": "0x8a2be2", + "brown": "0xa52a2a", + "burlywood": "0xdeb887", + "cadetblue": "0x5f9ea0", + "chartreuse": "0x7fff00", + "chocolate": "0xd2691e", + "coral": "0xff7f50", + "cornflowerblue": "0x6495ed", + "cornsilk": "0xfff8dc", + "crimson": "0xdc143c", + "cyan": "0x00ffff", + "darkblue": "0x00008b", + "darkcyan": "0x008b8b", + "darkgoldenrod": "0xb8860b", + "darkgray": "0xa9a9a9", + "darkgreen": "0x006400", + "darkgrey": "0xa9a9a9", + "darkkhaki": "0xbdb76b", + "darkmagenta": "0x8b008b", + "darkolivegreen": "0x556b2f", + "darkorange": "0xff8c00", + "darkorchid": "0x9932cc", + "darkred": "0x8b0000", + "darksalmon": "0xe9967a", + "darkseagreen": "0x8fbc8f", + "darkslateblue": "0x483d8b", + "darkslategray": "0x2f4f4f", + "darkslategrey": "0x2f4f4f", + "darkturquoise": "0x00ced1", + "darkviolet": "0x9400d3", + "deeppink": "0xff1493", + "deepskyblue": "0x00bfff", + "dimgray": "0x696969", + "dimgrey": "0x696969", + "dodgerblue": "0x1e90ff", + "firebrick": "0xb22222", + "floralwhite": "0xfffaf0", + "forestgreen": "0x228b22", + "fuchsia": "0xff00ff", + "gainsboro": "0xdcdcdc", + "ghostwhite": "0xf8f8ff", + "goldenrod": "0xdaa520", + "gold": "0xffd700", + "gray": "0x808080", + "green": "0x008000", + "greenyellow": "0xadff2f", + "grey": "0x808080", + "honeydew": "0xf0fff0", + "hotpink": "0xff69b4", + "indianred": "0xcd5c5c", + "indigo": "0x4b0082", + "ivory": "0xfffff0", + "khaki": "0xf0e68c", + "lavenderblush": "0xfff0f5", + "lavender": "0xe6e6fa", + "lawngreen": "0x7cfc00", + "lemonchiffon": "0xfffacd", + "lightblue": "0xadd8e6", + "lightcoral": "0xf08080", + "lightcyan": "0xe0ffff", + "lightgoldenrodyellow": "0xfafad2", + "lightgray": "0xd3d3d3", + "lightgreen": "0x90ee90", + "lightgrey": "0xd3d3d3", + "lightpink": "0xffb6c1", + "lightsalmon": "0xffa07a", + "lightseagreen": "0x20b2aa", + "lightskyblue": "0x87cefa", + "lightslategray": "0x778899", + "lightslategrey": "0x778899", + "lightsteelblue": "0xb0c4de", + "lightyellow": "0xffffe0", + "lime": "0x00ff00", + "limegreen": "0x32cd32", + "linen": "0xfaf0e6", + "magenta": "0xff00ff", + "maroon": "0x800000", + "mediumaquamarine": "0x66cdaa", + "mediumblue": "0x0000cd", + "mediumorchid": "0xba55d3", + "mediumpurple": "0x9370db", + "mediumseagreen": "0x3cb371", + "mediumslateblue": "0x7b68ee", + "mediumspringgreen": "0x00fa9a", + "mediumturquoise": "0x48d1cc", + "mediumvioletred": "0xc71585", + "midnightblue": "0x191970", + "mintcream": "0xf5fffa", + "mistyrose": "0xffe4e1", + "moccasin": "0xffe4b5", + "navajowhite": "0xffdead", + "navy": "0x000080", + "oldlace": "0xfdf5e6", + "olive": "0x808000", + "olivedrab": "0x6b8e23", + "orange": "0xffa500", + "orangered": "0xff4500", + "orchid": "0xda70d6", + "palegoldenrod": "0xeee8aa", + "palegreen": "0x98fb98", + "paleturquoise": "0xafeeee", + "palevioletred": "0xdb7093", + "papayawhip": "0xffefd5", + "peachpuff": "0xffdab9", + "peru": "0xcd853f", + "pink": "0xffc0cb", + "plum": "0xdda0dd", + "powderblue": "0xb0e0e6", + "purple": "0x800080", + "rebeccapurple": "0x663399", + "red": "0xff0000", + "rosybrown": "0xbc8f8f", + "royalblue": "0x4169e1", + "saddlebrown": "0x8b4513", + "salmon": "0xfa8072", + "sandybrown": "0xf4a460", + "seagreen": "0x2e8b57", + "seashell": "0xfff5ee", + "sienna": "0xa0522d", + "silver": "0xc0c0c0", + "skyblue": "0x87ceeb", + "slateblue": "0x6a5acd", + "slategray": "0x708090", + "slategrey": "0x708090", + "snow": "0xfffafa", + "springgreen": "0x00ff7f", + "steelblue": "0x4682b4", + "tan": "0xd2b48c", + "teal": "0x008080", + "thistle": "0xd8bfd8", + "tomato": "0xff6347", + "turquoise": "0x40e0d0", + "violet": "0xee82ee", + "wheat": "0xf5deb3", + "white": "0xffffff", + "whitesmoke": "0xf5f5f5", + "yellow": "0xffff00", + "yellowgreen": "0x9acd32" +} diff --git a/bot/resources/fun/magic8ball.json b/bot/resources/fun/magic8ball.json new file mode 100644 index 00000000..f5f1df62 --- /dev/null +++ b/bot/resources/fun/magic8ball.json @@ -0,0 +1,22 @@ +[ + "It is certain", + "It is decidedly so", + "Without a doubt", + "Yes definitely", + "You may rely on it", + "As I see it, yes", + "Most likely", + "Outlook good", + "Yes", + "Signs point to yes", + "Reply hazy try again", + "Ask again later", + "Better not tell you now", + "Cannot predict now", + "Concentrate and ask again", + "Don't count on it", + "My reply is no", + "My sources say no", + "Outlook not so good", + "Very doubtful" +] diff --git a/bot/resources/fun/speedrun_links.json b/bot/resources/fun/speedrun_links.json new file mode 100644 index 00000000..acb5746a --- /dev/null +++ b/bot/resources/fun/speedrun_links.json @@ -0,0 +1,18 @@ + [ + "https://www.youtube.com/watch?v=jNE28SDXdyQ", + "https://www.youtube.com/watch?v=iI8Giq7zQDk", + "https://www.youtube.com/watch?v=VqNnkqQgFbc", + "https://www.youtube.com/watch?v=Gum4GI2Jr0s", + "https://www.youtube.com/watch?v=5YHjHzHJKkU", + "https://www.youtube.com/watch?v=X0pJSTy4tJI", + "https://www.youtube.com/watch?v=aVFq0H6D6_M", + "https://www.youtube.com/watch?v=1O6LuJbEbSI", + "https://www.youtube.com/watch?v=Bgh30BiWG58", + "https://www.youtube.com/watch?v=wwvgAAvhxM8", + "https://www.youtube.com/watch?v=0TWQr0_fi80", + "https://www.youtube.com/watch?v=hatqZby-0to", + "https://www.youtube.com/watch?v=tmnMq2Hw72w", + "https://www.youtube.com/watch?v=UTkyeTCAucA", + "https://www.youtube.com/watch?v=67kQ3l-1qMs", + "https://www.youtube.com/watch?v=14wqBA5Q1yc" +] diff --git a/bot/resources/fun/trivia_quiz.json b/bot/resources/fun/trivia_quiz.json new file mode 100644 index 00000000..8008838c --- /dev/null +++ b/bot/resources/fun/trivia_quiz.json @@ -0,0 +1,912 @@ +{ + "retro": [ + { + "id": 1, + "hints": [ + "It is not a mainline Mario Game, although the plumber is present.", + "It is not a mainline Zelda Game, although Link is present." + ], + "question": "What is the best selling game on the Nintendo GameCube?", + "answer": "Super Smash Bros" + }, + { + "id": 2, + "hints": [ + "It was released before the 90's.", + "It was released after 1980." + ], + "question": "What year was Tetris released?", + "answer": "1984" + }, + { + "id": 3, + "hints": [ + "The occupation was in construction", + "He appeared as this kind of worker in 1981's Donkey Kong" + ], + "question": "What was Mario's original occupation?", + "answer": "Carpenter" + }, + { + "id": 4, + "hints": [ + "It was revealed in the Nintendo Character Guide in 1993.", + "His last name has to do with eating Mario's enemies." + ], + "question": "What is Yoshi's (from Mario Bros.) full name?", + "answer": "Yoshisaur Munchakoopas" + }, + { + "id": 5, + "hints": [ + "The game was released in 1990.", + "It was released on the SNES." + ], + "question": "What was the first game Yoshi appeared in?", + "answer": "Super Mario World" + }, + { + "id": 6, + "hints": [ + "They were used alternatively to playing cards.", + "They generally have handdrawn nature images on them." + ], + "question": "What did Nintendo make before video games and toys?", + "answer": "Hanafuda, Hanafuda cards" + }, + { + "id": 7, + "hints": [ + "Before being Nintendo's main competitor in home gaming, they were successful in arcades.", + "Their first console was called the Master System." + ], + "question": "Who was Nintendo's biggest competitor in 1990?", + "answer": "Sega" + } + ], + "general": [ + { + "id": 100, + "question": "Name \"the land of a thousand lakes\"", + "answer": "Finland", + "info": "Finland is a country in Northern Europe. Sweden borders it to the northwest, Estonia to the south, Russia to the east, and Norway to the north. Finland is part of the European Union with its capital city being Helsinki. With a population of 5.5 million people, it has over 187,000 lakes. The thousands of lakes in Finland are the reason why the country's nickname is \"the land of a thousand lakes.\"" + }, + { + "id": 101, + "question": "Who was the winner of FIFA 2018?", + "answer": "France", + "info": "France 4 - 2 Croatia" + }, + { + "id": 102, + "question": "What is the largest ocean in the world?", + "answer": "Pacific", + "info": "The Pacific Ocean is the largest and deepest of the world ocean basins. Covering approximately 63 million square miles and containing more than half of the free water on Earth, the Pacific is by far the largest of the world's ocean basins." + }, + { + "id": 103, + "question": "Who gifted the Statue Of Liberty?", + "answer": "France", + "info": "The Statue of Liberty was a gift from the French people commemorating the alliance of France and the United States during the American Revolution. Yet, it represented much more to those individuals who proposed the gift." + }, + { + "id": 104, + "question": "Which country is known as the \"Land Of The Rising Sun\"?", + "answer": "Japan", + "info": "The title stems from the Japanese names for Japan, Nippon/Nihon, both literally translating to \"the suns origin\"." + }, + { + "id": 105, + "question": "What's known as the \"Playground of Europe\"?", + "answer": "Switzerland", + "info": "It comes from the title of a book written in 1870 by Leslie Stephen (father of Virginia Woolf) detailing his exploits of mountain climbing (not skiing) of which sport he was one of the pioneers and trekking or walking." + }, + { + "id": 106, + "question": "Which country is known as the \"Land of Thunderbolt\"?", + "answer": "Bhutan", + "info": "Bhutan is known as the \"Land of Thunder Dragon\" or \"Land of Thunderbolt\" due to the violent and large thunderstorms that whip down through the valleys from the Himalayas. The dragon reference was due to people thinking the sparkling light of thunderbolts was the red fire of a dragon." + }, + { + "id": 107, + "question": "Which country is the largest producer of tea in the world?", + "answer": "China", + "info": "Tea is mainly grown in Asia, Africa, South America, and around the Black and Caspian Seas. The four biggest tea-producing countries today are China, India, Sri Lanka and Kenya. Together they represent 75% of world production." + }, + { + "id": 108, + "question": "Which country is the largest producer of coffee?", + "answer": "Brazil", + "info": "Brazil is the world's largest coffee producer. In 2016, Brazil produced a staggering 2,595,000 metric tons of coffee beans. It is not a new development, as Brazil has been the highest global producer of coffee beans for over 150 years." + }, + { + "id": 109, + "question": "Which country is Mount Etna, one of the most active volcanoes in the world, located?", + "answer": "Italy", + "info": "Mount Etna is the highest volcano in Europe. Towering above the city of Catania on the island of Sicily, it has been growing for about 500,000 years and is in the midst of a series of eruptions that began in 2001." + }, + { + "id": 110, + "question": "Which country is called \"Battleground of Europe?\"", + "answer": "Belgium", + "info": "Belgium has been the \"Battleground of Europe\" since the Roman Empire as it had no natural protection from its larger neighbouring countries. The battles of Oudenaarde, Ramillies, Waterloo, Ypres and Bastogne were all fought on Belgian soil." + }, + { + "id": 111, + "question": "Which is the largest tropical rain forest in the world?", + "answer": "Amazon", + "info": "The Amazon is regarded as vital in the fight against global warming due to its ability to absorb carbon from the air. It's often referred to as the \"lungs of the Earth,\" as more than 20 per cent of the world's oxygen is produced there." + }, + { + "id": 112, + "question": "Which is the largest island in the world?", + "answer": "Greenland", + "info": "Commonly thought to be Australia, but as it's actually a continental landmass, it doesn't get to make it in the list." + }, + { + "id": 113, + "question": "What's the name of the tallest waterfall in the world.", + "answer": "Angel Falls", + "info": "Angel Falls (Salto \u00c1ngel) in Venezuela is the highest waterfall in the world. The falls are 3230 feet in height, with an uninterrupted drop of 2647 feet. Angel Falls is located on a tributary of the Rio Caroni." + }, + { + "id": 114, + "question": "What country is called \"Land of White Elephants\"?", + "answer": "Thailand", + "info": "White elephants were regarded to be holy creatures in ancient Thailand and some other countries. Today, white elephants are still used as a symbol of divine and royal power in the country. Ownership of a white elephant symbolizes wealth, success, royalty, political power, wisdom, and prosperity." + }, + { + "id": 115, + "question": "Which city is in two continents?", + "answer": "Istanbul", + "info": "Istanbul embraces two continents, one arm reaching out to Asia, the other to Europe." + }, + { + "id": 116, + "question": "The Valley Of The Kings is located in which country?", + "answer": "Egypt", + "info": "The Valley of the Kings, also known as the Valley of the Gates of the Kings, is a valley in Egypt where, for a period of nearly 500 years from the 16th to 11th century BC, rock cut tombs were excavated for the pharaohs and powerful nobles of the New Kingdom (the Eighteenth to the Twentieth Dynasties of Ancient Egypt)." + }, + { + "id": 117, + "question": "Diamonds are always nice in Minecraft, but can you name the \"Diamond Capital in the World\"?", + "answer": "Antwerp", + "info": "Antwerp, Belgium is where 60-80% of the world's diamonds are cut and traded, and is known as the \"Diamond Capital of the World.\"" + }, + { + "id": 118, + "question": "Where is the \"International Court Of Justice\" located at?", + "answer": "The Hague", + "info": "" + }, + { + "id": 119, + "question": "In which country is Bali located in?", + "answer": "Indonesia", + "info": "" + }, + { + "id": 120, + "question": "What country is the world's largest coral reef system, the \"Great Barrier Reef\", located in?", + "answer": "Australia", + "info": "The Great Barrier Reef is the world's largest coral reef system composed of over 2,900 individual reefs and 900 islands stretching for over 2,300 kilometres (1,400 mi) over an area of approximately 344,400 square kilometres (133,000 sq mi). The reef is located in the Coral Sea, off the coast of Queensland, Australia." + }, + { + "id": 121, + "question": "When did the First World War start?", + "answer": "1914", + "info": "The first world war began in August 1914. It was directly triggered by the assassination of the Austrian archduke, Franz Ferdinand and his wife, on 28th June 1914 by Bosnian revolutionary, Gavrilo Princip. This event was, however, simply the trigger that set off declarations of war." + }, + { + "id": 122, + "question": "Which is the largest hot desert in the world?", + "answer": "Sahara", + "info": "The Sahara Desert covers 3.6 million square miles. It is almost the same size as the United States or China. There are sand dunes in the Sahara as tall as 590 feet." + }, + { + "id": 123, + "question": "Who lived at 221B, Baker Street, London?", + "answer": "Sherlock Holmes", + "info": "" + }, + { + "id": 124, + "question": "When did the Second World War end?", + "answer": "1945", + "info": "World War 2 ended with the unconditional surrender of the Axis powers. On 8 May 1945, the Allies accepted Germany's surrender, about a week after Adolf Hitler had committed suicide. VE Day \u2013 Victory in Europe celebrates the end of the Second World War on 8 May 1945." + }, + { + "id": 125, + "question": "What is the name of the largest dam in the world?", + "answer": "Three Gorges Dam", + "info": "At 1.4 miles wide (2.3 kilometers) and 630 feet (192 meters) high, Three Gorges Dam is the largest hydroelectric dam in the world, according to International Water Power & Dam Construction magazine. Three Gorges impounds the Yangtze River about 1,000 miles (1,610 km) west of Shanghai." + }, + { + "id": 126, + "question": "Which is the smallest planet in the Solar System?", + "answer": "Mercury", + "info": "Mercury is the smallest planet in our solar system. It's just a little bigger than Earth's moon. It is the closest planet to the sun, but it's actually not the hottest. Venus is hotter." + }, + { + "id": 127, + "question": "What is the smallest country?", + "answer": "Vatican City", + "info": "With an area of 0.17 square miles (0.44 km2) and a population right around 1,000, Vatican City is the smallest country in the world, both in terms of size and population." + }, + { + "id": 128, + "question": "What's the name of the largest bird?", + "answer": "Ostrich", + "info": "The largest living bird, a member of the Struthioniformes, is the ostrich (Struthio camelus), from the plains of Africa and Arabia. A large male ostrich can reach a height of 2.8 metres (9.2 feet) and weigh over 156 kilograms (344 pounds)." + }, + { + "id": 129, + "question": "What does the acronym GPRS stand for?", + "answer": "General Packet Radio Service", + "info": "General Packet Radio Service (GPRS) is a packet-based mobile data service on the global system for mobile communications (GSM) of 3G and 2G cellular communication systems. It is a non-voice, high-speed and useful packet-switching technology intended for GSM networks." + }, + { + "id": 130, + "question": "In what country is the Ebro river located?", + "answer": "Spain", + "info": "The Ebro river is located in Spain. It is 930 kilometers long and it's the second longest river that ends on the Mediterranean Sea." + }, + { + "id": 131, + "question": "What year was the IBM PC model 5150 introduced into the market?", + "answer": "1981", + "info": "The IBM PC was introduced into the market in 1981. It used the Intel 8088, with a clock speed of 4.77 MHz, along with the MDA and CGA as a video card." + }, + { + "id": 132, + "question": "What's the world's largest urban area?", + "answer": "Tokyo", + "info": "Tokyo is the most populated city in the world, with a population of 37 million people. It is located in Japan." + }, + { + "id": 133, + "question": "How many planets are there in the Solar system?", + "answer": "8", + "info": "In the Solar system, there are 8 planets: Mercury, Venus, Earth, Mars, Jupiter, Saturn, Uranus and Neptune. Pluto isn't considered a planet in the Solar System anymore." + }, + { + "id": 134, + "question": "What is the capital of Iraq?", + "answer": "Baghdad", + "info": "Baghdad is the capital of Iraq. It has a population of 7 million people." + }, + { + "id": 135, + "question": "The United Nations headquarters is located at which city?", + "answer": "New York", + "info": "The United Nations is headquartered in New York City in a complex designed by a board of architects led by Wallace Harrison and built by the architectural firm Harrison & Abramovitz. The complex has served as the official headquarters of the United Nations since its completion in 1951." + }, + { + "id": 136, + "question": "At what year did Christopher Columbus discover America?", + "answer": "1492", + "info": "The explorer Christopher Columbus made four trips across the Atlantic Ocean from Spain: in 1492, 1493, 1498 and 1502. He was determined to find a direct water route west from Europe to Asia, but he never did. Instead, he stumbled upon the Americas" + } + ], + "math": [ + { + "id": 201, + "question": "What is the highest power of a biquadratic polynomial?", + "answer": "4, four" + }, + { + "id": 202, + "question": "What is the formula for surface area of a sphere?", + "answer": "4pir^2, 4πr^2" + }, + { + "id": 203, + "question": "Which theorem states that hypotenuse^2 = base^2 + height^2?", + "answer": "Pythagorean's, Pythagorean's theorem" + }, + { + "id": 204, + "question": "Which trigonometric function is defined as hypotenuse/opposite?", + "answer": "cosecant, cosec, csc" + }, + { + "id": 205, + "question": "Does the harmonic series converge or diverge?", + "answer": "diverge" + }, + { + "id": 206, + "question": "How many quadrants are there in a cartesian plane?", + "answer": "4, four" + }, + { + "id": 207, + "question": "What is the (0,0) coordinate in a cartesian plane termed as?", + "answer": "origin" + }, + { + "id": 208, + "question": "What's the following formula that finds the area of a triangle called?", + "img_url": "https://wikimedia.org/api/rest_v1/media/math/render/png/d22b8566e8187542966e8d166e72e93746a1a6fc", + "answer": "Heron's formula, Heron" + }, + { + "id": 209, + "dynamic_id": 201, + "question": "Solve the following system of linear equations (format your answer like this & ):\n{}x + {}y = {},\n{}x + {}y = {}", + "answer": "{} & {}" + }, + { + "id": 210, + "dynamic_id": 202, + "question": "What's {} + {} mod {} congruent to?", + "answer": "{}" + }, + { + "id": 211, + "question": "What is the bottom number on a fraction called?", + "answer": "denominator" + }, + { + "id": 212, + "dynamic_id": 203, + "question": "How many vertices are on a {}gonal prism?", + "answer": "{}" + }, + { + "id": 213, + "question": "What is the term used to describe two triangles that have equal corresponding sides and angle measures?", + "answer": "congruent" + }, + { + "id": 214, + "question": "⅓πr^2h is the volume of which 3 dimensional figure?", + "answer": "cone" + }, + { + "id": 215, + "dynamic_id": 204, + "question": "Find the square root of -{}.", + "answer": "{}i" + }, + { + "id": 216, + "question": "In set builder notation, what does {p/q | q ≠ 0, p & q ∈ Z} represent?", + "answer": "Rationals, Rational Numbers" + }, + { + "id": 217, + "question": "What is the natural log of -1 (use i for imaginary number)?", + "answer": "pi*i, pii, πi" + }, + { + "id": 218, + "question": "When is the *inaugural* World Maths Day (format your answer in MM/DD)?", + "answer": "03/13" + }, + { + "id": 219, + "question": "As the Fibonacci sequence extends to infinity, what's the ratio of each number `n` and its preceding number `n-1` approaching?", + "answer": "Golden Ratio" + }, + { + "id": 220, + "question": "0, 1, 1, 2, 3, 5, 8, 13, 21, 34 are numbers of which sequence?", + "answer": "Fibonacci" + }, + { + "id": 221, + "question": "Prime numbers only have __ factors.", + "answer": "2, two" + }, + { + "id": 222, + "question": "In probability, the \\_\\_\\_\\_\\_\\_ \\_\\_\\_\\_\\_ of an experiment or random trial is the set of all possible outcomes of it.", + "answer": "sample space" + }, + { + "id": 223, + "question": "In statistics, what does this formula represent?", + "img_url": "https://www.statisticshowto.com/wp-content/uploads/2013/11/sample-standard-deviation.jpg", + "answer": "sample standard deviation, standard deviation of a sample" + }, + { + "id": 224, + "question": "\"Hexakosioihexekontahexaphobia\" is the fear of which number?", + "answer": "666" + }, + { + "id": 225, + "question": "A matrix multiplied by its inverse matrix equals...", + "answer": "the identity matrix, identity matrix" + }, + { + "id": 226, + "dynamic_id": 205, + "question": "BASE TWO QUESTION: Calculate {:b} {} {:b}", + "answer": "{:b}" + }, + { + "id": 227, + "question": "What is the only number in the entire number system which can be spelled with the same number of letters as itself?", + "answer": "4, four" + + }, + { + "id": 228, + "question": "1/100th of a second is also termed as what?", + "answer": "a jiffy, jiffy, centisecond" + }, + { + "id": 229, + "question": "What is this triangle called?", + "img_url": "https://cdn.askpython.com/wp-content/uploads/2020/07/Pascals-triangle.png", + "answer": "Pascal's triangle, Pascal" + }, + { + "id": 230, + "question": "6a^2 is the surface area of which 3 dimensional figure?", + "answer": "cube" + } + ], + "science": [ + { + "id": 301, + "question": "The three main components of a normal atom are: protons, neutrons, and...", + "answer": "electrons" + }, + { + "id": 302, + "question": "As of 2021, how many elements are there in the Periodic Table?", + "answer": "118" + }, + { + "id": 303, + "question": "What is the universal force discovered by Newton that causes objects with mass to attract each other called?", + "answer": "gravity" + }, + { + "id": 304, + "question": "What do you call an organism composed of only one cell?", + "answer": "unicellular, single-celled" + }, + { + "id": 305, + "question": "The Heisenberg's Uncertainty Principle states that the position and \\_\\_\\_\\_\\_\\_\\_\\_ of a quantum object can't be both exactly measured at the same time.", + "answer": "velocity, momentum" + }, + { + "id": 306, + "question": "A \\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_ reaction is the one wherein an atom or a set of atoms is/are replaced by another atom or a set of atoms", + "answer": "displacement, exchange" + }, + { + "id": 307, + "question": "What is the process by which green plants and certain other organisms transform light energy into chemical energy?", + "answer": "photosynthesis" + }, + { + "id": 308, + "dynamic_id": 301, + "question": "What is the {} planet of our Solar System?", + "answer": "{}" + }, + { + "id": 309, + "dynamic_id": 302, + "question": "In the biological taxonomic hierarchy, what is placed directly above {}?", + "answer": "{}" + }, + { + "id": 310, + "dynamic_id": 303, + "question": "How does one describe the unit {} in SI base units?\n**IMPORTANT:** enclose answer in backticks, use \\* for multiplication, ^ for exponentiation, and place your base units in this order: m - kg - s - A", + "img_url": "https://i.imgur.com/NRzU6tf.png", + "answer": "`{}`" + }, + { + "id": 311, + "question": "How does one call the direct phase transition from gas to solid?", + "answer": "deposition" + }, + { + "id": 312, + "question": "What is the intermolecular force caused by temporary and induced dipoles?", + "answer": "LDF, London dispersion, London dispersion force" + }, + { + "id": 313, + "question": "What is the force that causes objects to float in fluids called?", + "answer": "buoyancy" + }, + { + "id": 314, + "question": "About how many neurons are in the human brain?\n(A. 1 billion, B. 10 billion, C. 100 billion, D. 300 billion)", + "answer": "C, 100 billion, 100 bil" + }, + { + "id": 315, + "question": "What is the name of our galaxy group in which the Milky Way resides?", + "answer": "Local Group" + }, + { + "id": 316, + "question": "Which cell organelle is nicknamed \"the powerhouse of the cell\"?", + "answer": "mitochondria" + }, + { + "id": 317, + "question": "Which vascular tissue transports water and minerals from the roots to the rest of a plant?", + "answer": "the xylem, xylem" + }, + { + "id": 318, + "question": "Who discovered the theories of relativity?", + "answer": "Albert Einstein, Einstein" + }, + { + "id": 319, + "question": "In particle physics, the hypothetical isolated elementary particle with only one magnetic pole is termed as...", + "answer": "magnetic monopole" + }, + { + "id": 320, + "question": "How does one describe a chemical reaction wherein heat is released?", + "answer": "exothermic" + }, + { + "id": 321, + "question": "What range of frequency are the average human ears capable of hearing?\n(A. 10Hz-10kHz, B. 20Hz-20kHz, C. 20Hz-2000Hz, D. 10kHz-20kHz)", + "answer": "B, 20Hz-20kHz" + }, + { + "id": 322, + "question": "What is the process used to separate substances with different polarity in a mixture, using a stationary and mobile phase?", + "answer": "chromatography" + }, + { + "id": 323, + "question": "Which law states that the current through a conductor between two points is directly proportional to the voltage across the two points?", + "answer": "Ohm's law" + }, + { + "id": 324, + "question": "The type of rock that is formed by the accumulation or deposition of mineral or organic particles at the Earth's surface, followed by cementation, is called...", + "answer": "sedimentary, sedimentary rock" + }, + { + "id": 325, + "question": "Is the Richter scale (common earthquake scale) linear or logarithmic?", + "answer": "logarithmic" + }, + { + "id": 326, + "question": "What type of image is formed by a convex mirror?", + "answer": "virtual image, virtual" + }, + { + "id": 327, + "question": "How does one call the branch of physics that deals with the study of mechanical waves in gases, liquids, and solids including topics such as vibration, sound, ultrasound and infrasound", + "answer": "acoustics" + }, + { + "id": 328, + "question": "Which law states that the global entropy in a closed system can only increase?", + "answer": "second law, second law of thermodynamics" + }, + { + "id": 329, + "question": "Which particle is emitted during the beta decay of a radioactive element?", + "answer": "an electron, the electron, electron" + }, + { + "id": 330, + "question": "When DNA is unzipped, two strands are formed. What are they called (separate both answers by the word \"and\")?", + "answer": "leading and lagging, leading strand and lagging strand" + } + ], + "cs": [ + { + "id": 401, + "question": "What does HTML stand for?", + "answer": "HyperText Markup Language" + }, + { + "id": 402, + "question": "What does ASCII stand for?", + "answer": "American Standard Code for Information Interchange" + }, + { + "id": 403, + "question": "What does SASS stand for?", + "answer": "Syntactically Awesome Stylesheets, Syntactically Awesome Style Sheets" + }, + { + "id": 404, + "question": "In neural networks, \\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_ is an algorithm for supervised learning using gradient descent.", + "answer": "backpropagation" + }, + { + "id": 405, + "question": "What is computing capable of performing exaFLOPS called?", + "answer": "exascale computing, exascale" + }, + { + "id": 406, + "question": "In quantum computing, what is the full name of \"qubit\"?", + "answer": "quantum binary digit" + }, + { + "id": 407, + "question": "Given that January 1, 1970 is the starting epoch of time_t in c time, and that time_t is stored as a signed 32-bit integer, when will unix time roll over (year)?", + "answer": "2038" + }, + { + "id": 408, + "question": "What are the components of digital devices that make up logic gates called?", + "answer": "transistors" + }, + { + "id": 409, + "question": "How many possible public IPv6 addresses are there (answer in 2^n)?", + "answer": "2^128" + }, + { + "id": 410, + "question": "A hypothetical point in time at which technological growth becomes uncontrollable and irreversible, resulting in unforeseeable changes to human civilization is termed as...?", + "answer": "technological singularity, singularity" + }, + { + "id": 411, + "question": "In cryptography, the practice of establishing a shared secret between two parties using public keys and private keys is called...?", + "answer": "key exchange" + }, + { + "id": 412, + "question": "How many bits are in a TCP checksum header?", + "answer": "16, sixteen" + }, + { + "id": 413, + "question": "What is the most popular protocol (as of 2021) that handles communication between email servers?", + "answer": "SMTP, Simple Mail Transfer Protocol" + }, + { + "id": 414, + "question": "Which port does SMTP use to communicate between email servers? (assuming its plaintext)", + "answer": "25" + }, + { + "id": 415, + "question": "Which DNS record contains mail servers of a given domain?", + "answer": "MX, mail exchange" + }, + { + "id": 416, + "question": "Which newline sequence does HTTP use?", + "answer": "carriage return line feed, CRLF, \\r\\n" + }, + { + "id": 417, + "question": "What does one call the optimization technique used in CPU design that attempts to guess the outcome of a conditional operation and prepare for the most likely result?", + "answer": "branch prediction" + }, + { + "id": 418, + "question": "Name a universal logic gate.", + "answer": "NAND, NOR" + }, + { + "id": 419, + "question": "What is the mathematical formalism which functional programming was built on?", + "answer": "lambda calculus" + }, + { + "id": 420, + "question": "Why is a DDoS attack different from a DoS attack?\n(A. because the victim's server was indefinitely disrupted from the amount of traffic, B. because it also attacks the victim's confidentiality, C. because the attack had political purposes behind it, D. because the traffic flooding the victim originated from many different sources)", + "answer": "D" + }, + { + "id": 421, + "question": "What is a HTTP/1.1 feature that was superseded by HTTP/2 multiplexing and is unsupported in most browsers nowadays?", + "answer": "pipelining" + }, + { + "id": 422, + "question": "Which of these languages is the oldest?\n(Tcl, Smalltalk 80, Haskell, Standard ML, Java)", + "answer": "Smalltalk 80" + }, + { + "id": 423, + "question": "What is the name for unicode codepoints that do not fit into 16 bits?", + "answer": "surrogates" + }, + { + "id": 424, + "question": "Under what locale does making a string lowercase behave differently?", + "answer": "Turkish" + }, + { + "id": 425, + "question": "What does the \"a\" represent in a HSLA color value?", + "answer": "transparency, translucency, alpha value, alpha channel, alpha" + }, + { + "id": 426, + "question": "What is the section of a GIF that is limited to 256 colors called?", + "answer": "image block" + }, + { + "id": 427, + "question": "What is an interpreter capable of interpreting itself called?", + "answer": "metainterpreter" + }, + { + "id": 428, + "question": "Due to what data storage medium did old programming languages, such as cobol, ignore all characters past the 72nd column?", + "answer": "punch cards" + }, + { + "id": 429, + "question": "Which of these sorting algorithms is not stable?\n(Counting sort, quick sort, insertion sort, tim sort, bubble sort)", + "answer": "quick, quick sort" + }, + { + "id": 430, + "question": "Which of these languages is the youngest?\n(Lisp, Python, Java, Haskell, Prolog, Ruby, Perl)", + "answer": "Java" + } + ], + "python": [ + { + "id": 501, + "question": "Is everything an instance of the `object` class (y/n)?", + "answer": "y, yes" + }, + { + "id": 502, + "question": "Name the only non-dunder method of the builtin slice object.", + "answer": "indices" + }, + { + "id": 503, + "question": "What exception, other than `StopIteration`, can you raise from a `__getitem__` dunder to indicate to an iterator that it should stop?", + "answer": "IndexError" + }, + { + "id": 504, + "question": "What type does the `&` operator return when given 2 `dict_keys` objects?", + "answer": "set" + }, + { + "id": 505, + "question": "Can you pickle a running `list_iterator` (y/n)?", + "answer": "y, yes" + }, + { + "id": 506, + "question": "What attribute of a closure contains the value closed over?", + "answer": "cell_contents" + }, + { + "id": 507, + "question": "What name does a lambda function have?", + "answer": "" + }, + { + "id": 508, + "question": "Which file contains all special site builtins, such as help or credits?", + "answer": "_sitebuiltins" + }, + { + "id": 509, + "question": "Which module when imported opens up a web browser tab that points to the classic 353 XKCD comic mentioning Python?", + "answer": "antigravity" + }, + { + "id": 510, + "question": "Which attribute is the documentation string of a function/method/class stored in (answer should be enclosed in backticks!)?", + "answer": "`__doc__`" + }, + { + "id": 511, + "question": "What is the official name of this operator `:=`, introduced in 3.8?", + "answer": "assignment-expression operator" + }, + { + "id": 512, + "question": "When was Python first released?", + "answer": "1991" + }, + { + "id": 513, + "question": "Where does the name Python come from?", + "answer": "Monty Python, Monty Python's Flying Circus" + }, + { + "id": 514, + "question": "How is infinity represented in Python?", + "answer": "float(\"infinity\"), float('infinity'), float(\"inf\"), float('inf')" + }, + { + "id": 515, + "question": "Which of these characters is valid python outside of string literals in some context?\n(`@`, `$`, `?`)", + "answer": "@" + }, + { + "id": 516, + "question": "Which standard library module is designed for making simple parsers for languages like shell, as well as safe quoting of strings for use in a shell?", + "answer": "shlex" + }, + { + "id": 517, + "question": "Which one of these protocols/abstract base classes does the builtin `range` object NOT implement?\n(`Sequence`, `Iterable`, `Generator`)", + "answer": "Generator" + }, + { + "id": 518, + "question": "What decorator is used to allow a protocol to be checked at runtime?", + "answer": "runtime_checkable, typing.runtime_checkable" + }, + { + "id": 519, + "question": "Does `numbers.Rational` include the builtin object float (y/n)", + "answer": "n, no" + }, + { + "id": 520, + "question": "What is a package that doesn't have a `__init__` file called?", + "answer":"namespace package" + }, + { + "id": 521, + "question": "What file extension is used by the site module to determine what to do at every start?", + "answer": ".pth" + }, + { + "id": 522, + "question": "What is the garbage collection strategy used by cpython to collect everything but reference cycles?", + "answer": "reference counting, refcounting" + }, + { + "id": 523, + "question": "What dunder method is used by the tuple constructor to optimize converting an iterator to a tuple (answer should be enclosed in backticks!)?", + "answer": "`__length_hint__`" + }, + { + "id": 524, + "question": "Which protocol is used to pass self to methods when accessed on classes?", + "answer": "Descriptor" + }, + { + "id": 525, + "question": "Which year was Python 3 released?", + "answer": "2008" + }, + { + "id": 526, + "question": "Which of these is not a generator method?\n(`next`, `send`, `throw`, `close`)", + "answer": "next" + }, + { + "id": 527, + "question": "Is the `__aiter__` method async (y/n)?", + "answer": "n, no" + }, + { + "id": 528, + "question": "How does one call a class who defines the behavior of their instance classes?", + "answer": "a metaclass, metaclass" + }, + { + "id": 529, + "question": "Which of these is a subclass of `Exception`?\n(`NotImplemented`, `asyncio.CancelledError`, `StopIteration`)", + "answer": "StopIteration" + }, + { + "id": 530, + "question": "What type is the attribute of a frame object that contains the current local variables?", + "answer": "dict" + } + ] +} diff --git a/bot/resources/fun/wonder_twins.yaml b/bot/resources/fun/wonder_twins.yaml new file mode 100644 index 00000000..05e8d749 --- /dev/null +++ b/bot/resources/fun/wonder_twins.yaml @@ -0,0 +1,99 @@ +water_types: + - ice + - water + - steam + - snow + +objects: + - a bucket + - a spear + - a wall + - a lake + - a ladder + - a boat + - a vial + - a ski slope + - a hand + - a ramp + - clippers + - a bridge + - a dam + - a glacier + - a crowbar + - stilts + - a pole + - a hook + - a wave + - a cage + - a basket + - bolt cutters + - a trapeze + - a puddle + - a toboggan + - a gale + - a cloud + - a unicycle + - a spout + - a sheet + - a gelatin dessert + - a saw + - a geyser + - a jet + - a ball + - handcuffs + - a door + - a row + - a gondola + - a sled + - a rocket + - a swing + - a blizzard + - a saddle + - cubes + - a horse + - a knight + - a rocket pack + - a slick + - a drill + - a shield + - a crane + - a reflector + - a bowling ball + - a turret + - a catapault + - a blanket + - balls + - a faucet + - shears + - a thunder cloud + - a net + - a yoyo + - a block + - a straight-jacket + - a slingshot + - a jack + - a car + - a club + - a vault + - a storm + - a wrench + - an anchor + - a beast + +adjectives: + - a large + - a giant + - a massive + - a small + - a tiny + - a super cool + - a frozen + - a minuscule + - a minute + - a microscopic + - a very small + - a little + - a huge + - an enourmous + - a gigantic + - a great diff --git a/bot/resources/fun/xkcd_colours.json b/bot/resources/fun/xkcd_colours.json new file mode 100644 index 00000000..3feeb639 --- /dev/null +++ b/bot/resources/fun/xkcd_colours.json @@ -0,0 +1,951 @@ +{ + "cloudy blue": "0xacc2d9", + "dark pastel green": "0x56ae57", + "dust": "0xb2996e", + "electric lime": "0xa8ff04", + "fresh green": "0x69d84f", + "light eggplant": "0x894585", + "nasty green": "0x70b23f", + "really light blue": "0xd4ffff", + "tea": "0x65ab7c", + "warm purple": "0x952e8f", + "yellowish tan": "0xfcfc81", + "cement": "0xa5a391", + "dark grass green": "0x388004", + "dusty teal": "0x4c9085", + "grey teal": "0x5e9b8a", + "macaroni and cheese": "0xefb435", + "pinkish tan": "0xd99b82", + "spruce": "0x0a5f38", + "strong blue": "0x0c06f7", + "toxic green": "0x61de2a", + "windows blue": "0x3778bf", + "blue blue": "0x2242c7", + "blue with a hint of purple": "0x533cc6", + "booger": "0x9bb53c", + "bright sea green": "0x05ffa6", + "dark green blue": "0x1f6357", + "deep turquoise": "0x017374", + "green teal": "0x0cb577", + "strong pink": "0xff0789", + "bland": "0xafa88b", + "deep aqua": "0x08787f", + "lavender pink": "0xdd85d7", + "light moss green": "0xa6c875", + "light seafoam green": "0xa7ffb5", + "olive yellow": "0xc2b709", + "pig pink": "0xe78ea5", + "deep lilac": "0x966ebd", + "desert": "0xccad60", + "dusty lavender": "0xac86a8", + "purpley grey": "0x947e94", + "purply": "0x983fb2", + "candy pink": "0xff63e9", + "light pastel green": "0xb2fba5", + "boring green": "0x63b365", + "kiwi green": "0x8ee53f", + "light grey green": "0xb7e1a1", + "orange pink": "0xff6f52", + "tea green": "0xbdf8a3", + "very light brown": "0xd3b683", + "egg shell": "0xfffcc4", + "eggplant purple": "0x430541", + "powder pink": "0xffb2d0", + "reddish grey": "0x997570", + "baby shit brown": "0xad900d", + "liliac": "0xc48efd", + "stormy blue": "0x507b9c", + "ugly brown": "0x7d7103", + "custard": "0xfffd78", + "darkish pink": "0xda467d", + "deep brown": "0x410200", + "greenish beige": "0xc9d179", + "manilla": "0xfffa86", + "off blue": "0x5684ae", + "battleship grey": "0x6b7c85", + "browny green": "0x6f6c0a", + "bruise": "0x7e4071", + "kelley green": "0x009337", + "sickly yellow": "0xd0e429", + "sunny yellow": "0xfff917", + "azul": "0x1d5dec", + "darkgreen": "0x054907", + "green/yellow": "0xb5ce08", + "lichen": "0x8fb67b", + "light light green": "0xc8ffb0", + "pale gold": "0xfdde6c", + "sun yellow": "0xffdf22", + "tan green": "0xa9be70", + "burple": "0x6832e3", + "butterscotch": "0xfdb147", + "toupe": "0xc7ac7d", + "dark cream": "0xfff39a", + "indian red": "0x850e04", + "light lavendar": "0xefc0fe", + "poison green": "0x40fd14", + "baby puke green": "0xb6c406", + "bright yellow green": "0x9dff00", + "charcoal grey": "0x3c4142", + "squash": "0xf2ab15", + "cinnamon": "0xac4f06", + "light pea green": "0xc4fe82", + "radioactive green": "0x2cfa1f", + "raw sienna": "0x9a6200", + "baby purple": "0xca9bf7", + "cocoa": "0x875f42", + "light royal blue": "0x3a2efe", + "orangeish": "0xfd8d49", + "rust brown": "0x8b3103", + "sand brown": "0xcba560", + "swamp": "0x698339", + "tealish green": "0x0cdc73", + "burnt siena": "0xb75203", + "camo": "0x7f8f4e", + "dusk blue": "0x26538d", + "fern": "0x63a950", + "old rose": "0xc87f89", + "pale light green": "0xb1fc99", + "peachy pink": "0xff9a8a", + "rosy pink": "0xf6688e", + "light bluish green": "0x76fda8", + "light bright green": "0x53fe5c", + "light neon green": "0x4efd54", + "light seafoam": "0xa0febf", + "tiffany blue": "0x7bf2da", + "washed out green": "0xbcf5a6", + "browny orange": "0xca6b02", + "nice blue": "0x107ab0", + "sapphire": "0x2138ab", + "greyish teal": "0x719f91", + "orangey yellow": "0xfdb915", + "parchment": "0xfefcaf", + "straw": "0xfcf679", + "very dark brown": "0x1d0200", + "terracota": "0xcb6843", + "ugly blue": "0x31668a", + "clear blue": "0x247afd", + "creme": "0xffffb6", + "foam green": "0x90fda9", + "grey/green": "0x86a17d", + "light gold": "0xfddc5c", + "seafoam blue": "0x78d1b6", + "topaz": "0x13bbaf", + "violet pink": "0xfb5ffc", + "wintergreen": "0x20f986", + "yellow tan": "0xffe36e", + "dark fuchsia": "0x9d0759", + "indigo blue": "0x3a18b1", + "light yellowish green": "0xc2ff89", + "pale magenta": "0xd767ad", + "rich purple": "0x720058", + "sunflower yellow": "0xffda03", + "green/blue": "0x01c08d", + "leather": "0xac7434", + "racing green": "0x014600", + "vivid purple": "0x9900fa", + "dark royal blue": "0x02066f", + "hazel": "0x8e7618", + "muted pink": "0xd1768f", + "booger green": "0x96b403", + "canary": "0xfdff63", + "cool grey": "0x95a3a6", + "dark taupe": "0x7f684e", + "darkish purple": "0x751973", + "true green": "0x089404", + "coral pink": "0xff6163", + "dark sage": "0x598556", + "dark slate blue": "0x214761", + "flat blue": "0x3c73a8", + "mushroom": "0xba9e88", + "rich blue": "0x021bf9", + "dirty purple": "0x734a65", + "greenblue": "0x23c48b", + "icky green": "0x8fae22", + "light khaki": "0xe6f2a2", + "warm blue": "0x4b57db", + "dark hot pink": "0xd90166", + "deep sea blue": "0x015482", + "carmine": "0x9d0216", + "dark yellow green": "0x728f02", + "pale peach": "0xffe5ad", + "plum purple": "0x4e0550", + "golden rod": "0xf9bc08", + "neon red": "0xff073a", + "old pink": "0xc77986", + "very pale blue": "0xd6fffe", + "blood orange": "0xfe4b03", + "grapefruit": "0xfd5956", + "sand yellow": "0xfce166", + "clay brown": "0xb2713d", + "dark blue grey": "0x1f3b4d", + "flat green": "0x699d4c", + "light green blue": "0x56fca2", + "warm pink": "0xfb5581", + "dodger blue": "0x3e82fc", + "gross green": "0xa0bf16", + "ice": "0xd6fffa", + "metallic blue": "0x4f738e", + "pale salmon": "0xffb19a", + "sap green": "0x5c8b15", + "algae": "0x54ac68", + "bluey grey": "0x89a0b0", + "greeny grey": "0x7ea07a", + "highlighter green": "0x1bfc06", + "light light blue": "0xcafffb", + "light mint": "0xb6ffbb", + "raw umber": "0xa75e09", + "vivid blue": "0x152eff", + "deep lavender": "0x8d5eb7", + "dull teal": "0x5f9e8f", + "light greenish blue": "0x63f7b4", + "mud green": "0x606602", + "pinky": "0xfc86aa", + "red wine": "0x8c0034", + "shit green": "0x758000", + "tan brown": "0xab7e4c", + "darkblue": "0x030764", + "rosa": "0xfe86a4", + "lipstick": "0xd5174e", + "pale mauve": "0xfed0fc", + "claret": "0x680018", + "dandelion": "0xfedf08", + "orangered": "0xfe420f", + "poop green": "0x6f7c00", + "ruby": "0xca0147", + "dark": "0x1b2431", + "greenish turquoise": "0x00fbb0", + "pastel red": "0xdb5856", + "piss yellow": "0xddd618", + "bright cyan": "0x41fdfe", + "dark coral": "0xcf524e", + "algae green": "0x21c36f", + "darkish red": "0xa90308", + "reddy brown": "0x6e1005", + "blush pink": "0xfe828c", + "camouflage green": "0x4b6113", + "lawn green": "0x4da409", + "putty": "0xbeae8a", + "vibrant blue": "0x0339f8", + "dark sand": "0xa88f59", + "purple/blue": "0x5d21d0", + "saffron": "0xfeb209", + "twilight": "0x4e518b", + "warm brown": "0x964e02", + "bluegrey": "0x85a3b2", + "bubble gum pink": "0xff69af", + "duck egg blue": "0xc3fbf4", + "greenish cyan": "0x2afeb7", + "petrol": "0x005f6a", + "royal": "0x0c1793", + "butter": "0xffff81", + "dusty orange": "0xf0833a", + "off yellow": "0xf1f33f", + "pale olive green": "0xb1d27b", + "orangish": "0xfc824a", + "leaf": "0x71aa34", + "light blue grey": "0xb7c9e2", + "dried blood": "0x4b0101", + "lightish purple": "0xa552e6", + "rusty red": "0xaf2f0d", + "lavender blue": "0x8b88f8", + "light grass green": "0x9af764", + "light mint green": "0xa6fbb2", + "sunflower": "0xffc512", + "velvet": "0x750851", + "brick orange": "0xc14a09", + "lightish red": "0xfe2f4a", + "pure blue": "0x0203e2", + "twilight blue": "0x0a437a", + "violet red": "0xa50055", + "yellowy brown": "0xae8b0c", + "carnation": "0xfd798f", + "muddy yellow": "0xbfac05", + "dark seafoam green": "0x3eaf76", + "deep rose": "0xc74767", + "dusty red": "0xb9484e", + "grey/blue": "0x647d8e", + "lemon lime": "0xbffe28", + "purple/pink": "0xd725de", + "brown yellow": "0xb29705", + "purple brown": "0x673a3f", + "wisteria": "0xa87dc2", + "banana yellow": "0xfafe4b", + "lipstick red": "0xc0022f", + "water blue": "0x0e87cc", + "brown grey": "0x8d8468", + "vibrant purple": "0xad03de", + "baby green": "0x8cff9e", + "barf green": "0x94ac02", + "eggshell blue": "0xc4fff7", + "sandy yellow": "0xfdee73", + "cool green": "0x33b864", + "pale": "0xfff9d0", + "blue/grey": "0x758da3", + "hot magenta": "0xf504c9", + "greyblue": "0x77a1b5", + "purpley": "0x8756e4", + "baby shit green": "0x889717", + "brownish pink": "0xc27e79", + "dark aquamarine": "0x017371", + "diarrhea": "0x9f8303", + "light mustard": "0xf7d560", + "pale sky blue": "0xbdf6fe", + "turtle green": "0x75b84f", + "bright olive": "0x9cbb04", + "dark grey blue": "0x29465b", + "greeny brown": "0x696006", + "lemon green": "0xadf802", + "light periwinkle": "0xc1c6fc", + "seaweed green": "0x35ad6b", + "sunshine yellow": "0xfffd37", + "ugly purple": "0xa442a0", + "medium pink": "0xf36196", + "puke brown": "0x947706", + "very light pink": "0xfff4f2", + "viridian": "0x1e9167", + "bile": "0xb5c306", + "faded yellow": "0xfeff7f", + "very pale green": "0xcffdbc", + "vibrant green": "0x0add08", + "bright lime": "0x87fd05", + "spearmint": "0x1ef876", + "light aquamarine": "0x7bfdc7", + "light sage": "0xbcecac", + "yellowgreen": "0xbbf90f", + "baby poo": "0xab9004", + "dark seafoam": "0x1fb57a", + "deep teal": "0x00555a", + "heather": "0xa484ac", + "rust orange": "0xc45508", + "dirty blue": "0x3f829d", + "fern green": "0x548d44", + "bright lilac": "0xc95efb", + "weird green": "0x3ae57f", + "peacock blue": "0x016795", + "avocado green": "0x87a922", + "faded orange": "0xf0944d", + "grape purple": "0x5d1451", + "hot green": "0x25ff29", + "lime yellow": "0xd0fe1d", + "mango": "0xffa62b", + "shamrock": "0x01b44c", + "bubblegum": "0xff6cb5", + "purplish brown": "0x6b4247", + "vomit yellow": "0xc7c10c", + "pale cyan": "0xb7fffa", + "key lime": "0xaeff6e", + "tomato red": "0xec2d01", + "lightgreen": "0x76ff7b", + "merlot": "0x730039", + "night blue": "0x040348", + "purpleish pink": "0xdf4ec8", + "apple": "0x6ecb3c", + "baby poop green": "0x8f9805", + "green apple": "0x5edc1f", + "heliotrope": "0xd94ff5", + "yellow/green": "0xc8fd3d", + "almost black": "0x070d0d", + "cool blue": "0x4984b8", + "leafy green": "0x51b73b", + "mustard brown": "0xac7e04", + "dusk": "0x4e5481", + "dull brown": "0x876e4b", + "frog green": "0x58bc08", + "vivid green": "0x2fef10", + "bright light green": "0x2dfe54", + "fluro green": "0x0aff02", + "kiwi": "0x9cef43", + "seaweed": "0x18d17b", + "navy green": "0x35530a", + "ultramarine blue": "0x1805db", + "iris": "0x6258c4", + "pastel orange": "0xff964f", + "yellowish orange": "0xffab0f", + "perrywinkle": "0x8f8ce7", + "tealish": "0x24bca8", + "dark plum": "0x3f012c", + "pear": "0xcbf85f", + "pinkish orange": "0xff724c", + "midnight purple": "0x280137", + "light urple": "0xb36ff6", + "dark mint": "0x48c072", + "greenish tan": "0xbccb7a", + "light burgundy": "0xa8415b", + "turquoise blue": "0x06b1c4", + "ugly pink": "0xcd7584", + "sandy": "0xf1da7a", + "electric pink": "0xff0490", + "muted purple": "0x805b87", + "mid green": "0x50a747", + "greyish": "0xa8a495", + "neon yellow": "0xcfff04", + "banana": "0xffff7e", + "carnation pink": "0xff7fa7", + "tomato": "0xef4026", + "sea": "0x3c9992", + "muddy brown": "0x886806", + "turquoise green": "0x04f489", + "buff": "0xfef69e", + "fawn": "0xcfaf7b", + "muted blue": "0x3b719f", + "pale rose": "0xfdc1c5", + "dark mint green": "0x20c073", + "amethyst": "0x9b5fc0", + "blue/green": "0x0f9b8e", + "chestnut": "0x742802", + "sick green": "0x9db92c", + "pea": "0xa4bf20", + "rusty orange": "0xcd5909", + "stone": "0xada587", + "rose red": "0xbe013c", + "pale aqua": "0xb8ffeb", + "deep orange": "0xdc4d01", + "earth": "0xa2653e", + "mossy green": "0x638b27", + "grassy green": "0x419c03", + "pale lime green": "0xb1ff65", + "light grey blue": "0x9dbcd4", + "pale grey": "0xfdfdfe", + "asparagus": "0x77ab56", + "blueberry": "0x464196", + "purple red": "0x990147", + "pale lime": "0xbefd73", + "greenish teal": "0x32bf84", + "caramel": "0xaf6f09", + "deep magenta": "0xa0025c", + "light peach": "0xffd8b1", + "milk chocolate": "0x7f4e1e", + "ocher": "0xbf9b0c", + "off green": "0x6ba353", + "purply pink": "0xf075e6", + "lightblue": "0x7bc8f6", + "dusky blue": "0x475f94", + "golden": "0xf5bf03", + "light beige": "0xfffeb6", + "butter yellow": "0xfffd74", + "dusky purple": "0x895b7b", + "french blue": "0x436bad", + "ugly yellow": "0xd0c101", + "greeny yellow": "0xc6f808", + "orangish red": "0xf43605", + "shamrock green": "0x02c14d", + "orangish brown": "0xb25f03", + "tree green": "0x2a7e19", + "deep violet": "0x490648", + "gunmetal": "0x536267", + "blue/purple": "0x5a06ef", + "cherry": "0xcf0234", + "sandy brown": "0xc4a661", + "warm grey": "0x978a84", + "dark indigo": "0x1f0954", + "midnight": "0x03012d", + "bluey green": "0x2bb179", + "grey pink": "0xc3909b", + "soft purple": "0xa66fb5", + "blood": "0x770001", + "brown red": "0x922b05", + "medium grey": "0x7d7f7c", + "berry": "0x990f4b", + "poo": "0x8f7303", + "purpley pink": "0xc83cb9", + "light salmon": "0xfea993", + "snot": "0xacbb0d", + "easter purple": "0xc071fe", + "light yellow green": "0xccfd7f", + "dark navy blue": "0x00022e", + "drab": "0x828344", + "light rose": "0xffc5cb", + "rouge": "0xab1239", + "purplish red": "0xb0054b", + "slime green": "0x99cc04", + "baby poop": "0x937c00", + "irish green": "0x019529", + "pink/purple": "0xef1de7", + "dark navy": "0x000435", + "greeny blue": "0x42b395", + "light plum": "0x9d5783", + "pinkish grey": "0xc8aca9", + "dirty orange": "0xc87606", + "rust red": "0xaa2704", + "pale lilac": "0xe4cbff", + "orangey red": "0xfa4224", + "primary blue": "0x0804f9", + "kermit green": "0x5cb200", + "brownish purple": "0x76424e", + "murky green": "0x6c7a0e", + "wheat": "0xfbdd7e", + "very dark purple": "0x2a0134", + "bottle green": "0x044a05", + "watermelon": "0xfd4659", + "deep sky blue": "0x0d75f8", + "fire engine red": "0xfe0002", + "yellow ochre": "0xcb9d06", + "pumpkin orange": "0xfb7d07", + "pale olive": "0xb9cc81", + "light lilac": "0xedc8ff", + "lightish green": "0x61e160", + "carolina blue": "0x8ab8fe", + "mulberry": "0x920a4e", + "shocking pink": "0xfe02a2", + "auburn": "0x9a3001", + "bright lime green": "0x65fe08", + "celadon": "0xbefdb7", + "pinkish brown": "0xb17261", + "poo brown": "0x885f01", + "bright sky blue": "0x02ccfe", + "celery": "0xc1fd95", + "dirt brown": "0x836539", + "strawberry": "0xfb2943", + "dark lime": "0x84b701", + "copper": "0xb66325", + "medium brown": "0x7f5112", + "muted green": "0x5fa052", + "robin's egg": "0x6dedfd", + "bright aqua": "0x0bf9ea", + "bright lavender": "0xc760ff", + "ivory": "0xffffcb", + "very light purple": "0xf6cefc", + "light navy": "0x155084", + "pink red": "0xf5054f", + "olive brown": "0x645403", + "poop brown": "0x7a5901", + "mustard green": "0xa8b504", + "ocean green": "0x3d9973", + "very dark blue": "0x000133", + "dusty green": "0x76a973", + "light navy blue": "0x2e5a88", + "minty green": "0x0bf77d", + "adobe": "0xbd6c48", + "barney": "0xac1db8", + "jade green": "0x2baf6a", + "bright light blue": "0x26f7fd", + "light lime": "0xaefd6c", + "dark khaki": "0x9b8f55", + "orange yellow": "0xffad01", + "ocre": "0xc69c04", + "maize": "0xf4d054", + "faded pink": "0xde9dac", + "british racing green": "0x05480d", + "sandstone": "0xc9ae74", + "mud brown": "0x60460f", + "light sea green": "0x98f6b0", + "robin egg blue": "0x8af1fe", + "aqua marine": "0x2ee8bb", + "dark sea green": "0x11875d", + "soft pink": "0xfdb0c0", + "orangey brown": "0xb16002", + "cherry red": "0xf7022a", + "burnt yellow": "0xd5ab09", + "brownish grey": "0x86775f", + "camel": "0xc69f59", + "purplish grey": "0x7a687f", + "marine": "0x042e60", + "greyish pink": "0xc88d94", + "pale turquoise": "0xa5fbd5", + "pastel yellow": "0xfffe71", + "bluey purple": "0x6241c7", + "canary yellow": "0xfffe40", + "faded red": "0xd3494e", + "sepia": "0x985e2b", + "coffee": "0xa6814c", + "bright magenta": "0xff08e8", + "mocha": "0x9d7651", + "ecru": "0xfeffca", + "purpleish": "0x98568d", + "cranberry": "0x9e003a", + "darkish green": "0x287c37", + "brown orange": "0xb96902", + "dusky rose": "0xba6873", + "melon": "0xff7855", + "sickly green": "0x94b21c", + "silver": "0xc5c9c7", + "purply blue": "0x661aee", + "purpleish blue": "0x6140ef", + "hospital green": "0x9be5aa", + "shit brown": "0x7b5804", + "mid blue": "0x276ab3", + "amber": "0xfeb308", + "easter green": "0x8cfd7e", + "soft blue": "0x6488ea", + "cerulean blue": "0x056eee", + "golden brown": "0xb27a01", + "bright turquoise": "0x0ffef9", + "red pink": "0xfa2a55", + "red purple": "0x820747", + "greyish brown": "0x7a6a4f", + "vermillion": "0xf4320c", + "russet": "0xa13905", + "steel grey": "0x6f828a", + "lighter purple": "0xa55af4", + "bright violet": "0xad0afd", + "prussian blue": "0x004577", + "slate green": "0x658d6d", + "dirty pink": "0xca7b80", + "dark blue green": "0x005249", + "pine": "0x2b5d34", + "yellowy green": "0xbff128", + "dark gold": "0xb59410", + "bluish": "0x2976bb", + "darkish blue": "0x014182", + "dull red": "0xbb3f3f", + "pinky red": "0xfc2647", + "bronze": "0xa87900", + "pale teal": "0x82cbb2", + "military green": "0x667c3e", + "barbie pink": "0xfe46a5", + "bubblegum pink": "0xfe83cc", + "pea soup green": "0x94a617", + "dark mustard": "0xa88905", + "shit": "0x7f5f00", + "medium purple": "0x9e43a2", + "very dark green": "0x062e03", + "dirt": "0x8a6e45", + "dusky pink": "0xcc7a8b", + "red violet": "0x9e0168", + "lemon yellow": "0xfdff38", + "pistachio": "0xc0fa8b", + "dull yellow": "0xeedc5b", + "dark lime green": "0x7ebd01", + "denim blue": "0x3b5b92", + "teal blue": "0x01889f", + "lightish blue": "0x3d7afd", + "purpley blue": "0x5f34e7", + "light indigo": "0x6d5acf", + "swamp green": "0x748500", + "brown green": "0x706c11", + "dark maroon": "0x3c0008", + "hot purple": "0xcb00f5", + "dark forest green": "0x002d04", + "faded blue": "0x658cbb", + "drab green": "0x749551", + "light lime green": "0xb9ff66", + "snot green": "0x9dc100", + "yellowish": "0xfaee66", + "light blue green": "0x7efbb3", + "bordeaux": "0x7b002c", + "light mauve": "0xc292a1", + "ocean": "0x017b92", + "marigold": "0xfcc006", + "muddy green": "0x657432", + "dull orange": "0xd8863b", + "steel": "0x738595", + "electric purple": "0xaa23ff", + "fluorescent green": "0x08ff08", + "yellowish brown": "0x9b7a01", + "blush": "0xf29e8e", + "soft green": "0x6fc276", + "bright orange": "0xff5b00", + "lemon": "0xfdff52", + "purple grey": "0x866f85", + "acid green": "0x8ffe09", + "pale lavender": "0xeecffe", + "violet blue": "0x510ac9", + "light forest green": "0x4f9153", + "burnt red": "0x9f2305", + "khaki green": "0x728639", + "cerise": "0xde0c62", + "faded purple": "0x916e99", + "apricot": "0xffb16d", + "dark olive green": "0x3c4d03", + "grey brown": "0x7f7053", + "green grey": "0x77926f", + "true blue": "0x010fcc", + "pale violet": "0xceaefa", + "periwinkle blue": "0x8f99fb", + "light sky blue": "0xc6fcff", + "blurple": "0x5539cc", + "green brown": "0x544e03", + "bluegreen": "0x017a79", + "bright teal": "0x01f9c6", + "brownish yellow": "0xc9b003", + "pea soup": "0x929901", + "forest": "0x0b5509", + "barney purple": "0xa00498", + "ultramarine": "0x2000b1", + "purplish": "0x94568c", + "puke yellow": "0xc2be0e", + "bluish grey": "0x748b97", + "dark periwinkle": "0x665fd1", + "dark lilac": "0x9c6da5", + "reddish": "0xc44240", + "light maroon": "0xa24857", + "dusty purple": "0x825f87", + "terra cotta": "0xc9643b", + "avocado": "0x90b134", + "marine blue": "0x01386a", + "teal green": "0x25a36f", + "slate grey": "0x59656d", + "lighter green": "0x75fd63", + "electric green": "0x21fc0d", + "dusty blue": "0x5a86ad", + "golden yellow": "0xfec615", + "bright yellow": "0xfffd01", + "light lavender": "0xdfc5fe", + "umber": "0xb26400", + "poop": "0x7f5e00", + "dark peach": "0xde7e5d", + "jungle green": "0x048243", + "eggshell": "0xffffd4", + "denim": "0x3b638c", + "yellow brown": "0xb79400", + "dull purple": "0x84597e", + "chocolate brown": "0x411900", + "wine red": "0x7b0323", + "neon blue": "0x04d9ff", + "dirty green": "0x667e2c", + "light tan": "0xfbeeac", + "ice blue": "0xd7fffe", + "cadet blue": "0x4e7496", + "dark mauve": "0x874c62", + "very light blue": "0xd5ffff", + "grey purple": "0x826d8c", + "pastel pink": "0xffbacd", + "very light green": "0xd1ffbd", + "dark sky blue": "0x448ee4", + "evergreen": "0x05472a", + "dull pink": "0xd5869d", + "aubergine": "0x3d0734", + "mahogany": "0x4a0100", + "reddish orange": "0xf8481c", + "deep green": "0x02590f", + "vomit green": "0x89a203", + "purple pink": "0xe03fd8", + "dusty pink": "0xd58a94", + "faded green": "0x7bb274", + "camo green": "0x526525", + "pinky purple": "0xc94cbe", + "pink purple": "0xdb4bda", + "brownish red": "0x9e3623", + "dark rose": "0xb5485d", + "mud": "0x735c12", + "brownish": "0x9c6d57", + "emerald green": "0x028f1e", + "pale brown": "0xb1916e", + "dull blue": "0x49759c", + "burnt umber": "0xa0450e", + "medium green": "0x39ad48", + "clay": "0xb66a50", + "light aqua": "0x8cffdb", + "light olive green": "0xa4be5c", + "brownish orange": "0xcb7723", + "dark aqua": "0x05696b", + "purplish pink": "0xce5dae", + "dark salmon": "0xc85a53", + "greenish grey": "0x96ae8d", + "jade": "0x1fa774", + "ugly green": "0x7a9703", + "dark beige": "0xac9362", + "emerald": "0x01a049", + "pale red": "0xd9544d", + "light magenta": "0xfa5ff7", + "sky": "0x82cafc", + "light cyan": "0xacfffc", + "yellow orange": "0xfcb001", + "reddish purple": "0x910951", + "reddish pink": "0xfe2c54", + "orchid": "0xc875c4", + "dirty yellow": "0xcdc50a", + "orange red": "0xfd411e", + "deep red": "0x9a0200", + "orange brown": "0xbe6400", + "cobalt blue": "0x030aa7", + "neon pink": "0xfe019a", + "rose pink": "0xf7879a", + "greyish purple": "0x887191", + "raspberry": "0xb00149", + "aqua green": "0x12e193", + "salmon pink": "0xfe7b7c", + "tangerine": "0xff9408", + "brownish green": "0x6a6e09", + "red brown": "0x8b2e16", + "greenish brown": "0x696112", + "pumpkin": "0xe17701", + "pine green": "0x0a481e", + "charcoal": "0x343837", + "baby pink": "0xffb7ce", + "cornflower": "0x6a79f7", + "blue violet": "0x5d06e9", + "chocolate": "0x3d1c02", + "greyish green": "0x82a67d", + "scarlet": "0xbe0119", + "green yellow": "0xc9ff27", + "dark olive": "0x373e02", + "sienna": "0xa9561e", + "pastel purple": "0xcaa0ff", + "terracotta": "0xca6641", + "aqua blue": "0x02d8e9", + "sage green": "0x88b378", + "blood red": "0x980002", + "deep pink": "0xcb0162", + "grass": "0x5cac2d", + "moss": "0x769958", + "pastel blue": "0xa2bffe", + "bluish green": "0x10a674", + "green blue": "0x06b48b", + "dark tan": "0xaf884a", + "greenish blue": "0x0b8b87", + "pale orange": "0xffa756", + "vomit": "0xa2a415", + "forrest green": "0x154406", + "dark lavender": "0x856798", + "dark violet": "0x34013f", + "purple blue": "0x632de9", + "dark cyan": "0x0a888a", + "olive drab": "0x6f7632", + "pinkish": "0xd46a7e", + "cobalt": "0x1e488f", + "neon purple": "0xbc13fe", + "light turquoise": "0x7ef4cc", + "apple green": "0x76cd26", + "dull green": "0x74a662", + "wine": "0x80013f", + "powder blue": "0xb1d1fc", + "off white": "0xffffe4", + "electric blue": "0x0652ff", + "dark turquoise": "0x045c5a", + "blue purple": "0x5729ce", + "azure": "0x069af3", + "bright red": "0xff000d", + "pinkish red": "0xf10c45", + "cornflower blue": "0x5170d7", + "light olive": "0xacbf69", + "grape": "0x6c3461", + "greyish blue": "0x5e819d", + "purplish blue": "0x601ef9", + "yellowish green": "0xb0dd16", + "greenish yellow": "0xcdfd02", + "medium blue": "0x2c6fbb", + "dusty rose": "0xc0737a", + "light violet": "0xd6b4fc", + "midnight blue": "0x020035", + "bluish purple": "0x703be7", + "red orange": "0xfd3c06", + "dark magenta": "0x960056", + "greenish": "0x40a368", + "ocean blue": "0x03719c", + "coral": "0xfc5a50", + "cream": "0xffffc2", + "reddish brown": "0x7f2b0a", + "burnt sienna": "0xb04e0f", + "brick": "0xa03623", + "sage": "0x87ae73", + "grey green": "0x789b73", + "white": "0xffffff", + "robin's egg blue": "0x98eff9", + "moss green": "0x658b38", + "steel blue": "0x5a7d9a", + "eggplant": "0x380835", + "light yellow": "0xfffe7a", + "leaf green": "0x5ca904", + "light grey": "0xd8dcd6", + "puke": "0xa5a502", + "pinkish purple": "0xd648d7", + "sea blue": "0x047495", + "pale purple": "0xb790d4", + "slate blue": "0x5b7c99", + "blue grey": "0x607c8e", + "hunter green": "0x0b4008", + "fuchsia": "0xed0dd9", + "crimson": "0x8c000f", + "pale yellow": "0xffff84", + "ochre": "0xbf9005", + "mustard yellow": "0xd2bd0a", + "light red": "0xff474c", + "cerulean": "0x0485d1", + "pale pink": "0xffcfdc", + "deep blue": "0x040273", + "rust": "0xa83c09", + "light teal": "0x90e4c1", + "slate": "0x516572", + "goldenrod": "0xfac205", + "dark yellow": "0xd5b60a", + "dark grey": "0x363737", + "army green": "0x4b5d16", + "grey blue": "0x6b8ba4", + "seafoam": "0x80f9ad", + "puce": "0xa57e52", + "spring green": "0xa9f971", + "dark orange": "0xc65102", + "sand": "0xe2ca76", + "pastel green": "0xb0ff9d", + "mint": "0x9ffeb0", + "light orange": "0xfdaa48", + "bright pink": "0xfe01b1", + "chartreuse": "0xc1f80a", + "deep purple": "0x36013f", + "dark brown": "0x341c02", + "taupe": "0xb9a281", + "pea green": "0x8eab12", + "puke green": "0x9aae07", + "kelly green": "0x02ab2e", + "seafoam green": "0x7af9ab", + "blue green": "0x137e6d", + "khaki": "0xaaa662", + "burgundy": "0x610023", + "dark teal": "0x014d4e", + "brick red": "0x8f1402", + "royal purple": "0x4b006e", + "plum": "0x580f41", + "mint green": "0x8fff9f", + "gold": "0xdbb40c", + "baby blue": "0xa2cffe", + "yellow green": "0xc0fb2d", + "bright purple": "0xbe03fd", + "dark red": "0x840000", + "pale blue": "0xd0fefe", + "grass green": "0x3f9b0b", + "navy": "0x01153e", + "aquamarine": "0x04d8b2", + "burnt orange": "0xc04e01", + "neon green": "0x0cff0c", + "bright blue": "0x0165fc", + "rose": "0xcf6275", + "light pink": "0xffd1df", + "mustard": "0xceb301", + "indigo": "0x380282", + "lime": "0xaaff32", + "sea green": "0x53fca1", + "periwinkle": "0x8e82fe", + "dark pink": "0xcb416b", + "olive green": "0x677a04", + "peach": "0xffb07c", + "pale green": "0xc7fdb5", + "light brown": "0xad8150", + "hot pink": "0xff028d", + "black": "0x000000", + "lilac": "0xcea2fd", + "navy blue": "0x001146", + "royal blue": "0x0504aa", + "beige": "0xe6daa6", + "salmon": "0xff796c", + "olive": "0x6e750e", + "maroon": "0x650021", + "bright green": "0x01ff07", + "dark purple": "0x35063e", + "mauve": "0xae7181", + "forest green": "0x06470c", + "aqua": "0x13eac9", + "cyan": "0x00ffff", + "tan": "0xd1b26f", + "dark blue": "0x00035b", + "lavender": "0xc79fef", + "turquoise": "0x06c2ac", + "dark green": "0x033500", + "violet": "0x9a0eea", + "light purple": "0xbf77f6", + "lime green": "0x89fe05", + "grey": "0x929591", + "sky blue": "0x75bbfd", + "yellow": "0xffff14", + "magenta": "0xc20078", + "light green": "0x96f97b", + "orange": "0xf97306", + "teal": "0x029386", + "light blue": "0x95d0fc", + "red": "0xe50000", + "brown": "0x653700", + "pink": "0xff81c0", + "blue": "0x0343df", + "green": "0x15b01a", + "purple": "0x7e1e9c" +} -- cgit v1.2.3 From 940fc5e8fca765e6bc949d743b510313f1da5488 Mon Sep 17 00:00:00 2001 From: Janine vN Date: Sun, 5 Sep 2021 13:21:57 -0400 Subject: Update rogue paths to new paths --- bot/resources/holidays/halloween/spooky_rating.json | 18 +++++++++--------- bot/utils/extensions.py | 2 +- bot/utils/halloween/spookifications.py | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) (limited to 'bot') diff --git a/bot/resources/holidays/halloween/spooky_rating.json b/bot/resources/holidays/halloween/spooky_rating.json index 8e3e66bb..738a8717 100644 --- a/bot/resources/holidays/halloween/spooky_rating.json +++ b/bot/resources/holidays/halloween/spooky_rating.json @@ -2,46 +2,46 @@ "-1": { "title": "\uD83D\uDD6F You're not scarin' anyone \uD83D\uDD6F", "text": "No matter what you say or do, nobody even flinches when you try to scare them. Was your costume this year only a white sheet with holes for eyes? Or did you even bother with a costume at all? Either way, don't expect too many treats when going from door-to-door.", - "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/halloween/spookyrating/candle.jpeg" + "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/holidays/halloween/spookyrating/candle.jpeg" }, "5": { "title": "\uD83D\uDC76 Like taking candy from a baby \uD83D\uDC76", "text": "Your scaring will probably make a baby cry... but that's the limit on your frightening powers. Be careful not to get to the point where everyone's running away from you because they don't like you, not because they're scared of you.", - "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/halloween/spookyrating/baby.jpeg" + "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/holidays/halloween/spookyrating/baby.jpeg" }, "20": { "title": "\uD83C\uDFDA You're skills are forming... \uD83C\uDFDA", "text": "As you become the Devil's apprentice, you begin to make people jump every time you sneak up on them. A good start, but you have to learn not to wear the same costume every year until it doesn't fit you. People will notice you and your prowess will decrease.", - "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/halloween/spookyrating/tiger.jpeg" + "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/holidays/halloween/spookyrating/tiger.jpeg" }, "30": { "title": "\uD83D\uDC80 Picture Perfect... \uD83D\uDC80", "text": "You've nailed the costume this year! You look suuuper scary! Now make sure to play the part and act out your costume and you'll be sure to give a few people a massive fright!", - "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/halloween/spookyrating/costume.jpeg" + "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/holidays/halloween/spookyrating/costume.jpeg" }, "50": { "title": "\uD83D\uDC7B Uhm... are you human \uD83D\uDC7B", "text": "Uhm... you're too good to be human and now you're beginning to sound like a ghost. You're almost invisible when haunting and nobody truly knows where you are at any given time. But they will always scream at the sound of a ghost...", - "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/halloween/spookyrating/ghost.jpeg" + "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/holidays/halloween/spookyrating/ghost.jpeg" }, "65": { "title": "\uD83C\uDF83 That potion can't be real \uD83C\uDF83", "text": "You're carrying... some... unknown liquids and no one knows who they are but yourself. Be careful on who you use these powerful spells on, because no Mage has the power to do any irreversible enchantments because even you won't know what will happen to these mortals.", - "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/halloween/spookyrating/necromancer.jepg" + "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/holidays/halloween/spookyrating/necromancer.jepg" }, "80": { "title": "\uD83E\uDD21 The most sinister face \uD83E\uDD21", "text": "Who knew something intended to be playful could be so menacing... Especially other people seeing you in their nightmares, continuing to haunt them day by day, stuck in their head throughout the entire year. Make sure to pull a face they will never forget.", - "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/halloween/spookyrating/clown.jpeg" + "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/holidays/halloween/spookyrating/clown.jpeg" }, "95": { "title": "\uD83D\uDE08 The Devil's Accomplice \uD83D\uDE08", "text": "Imagine being allies with the most evil character with an aim to scare people to death. Force people to suffer as they proceed straight to hell to meet your boss and best friend. Not even you know the power He has...", - "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/halloween/spookyrating/jackolantern.jpg" + "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/holidays/halloween/spookyrating/jackolantern.jpg" }, "100": { "title":"\uD83D\uDC7F The Devil Himself \uD83D\uDC7F", "text": "You are the evillest creature in existence to scare anyone and everyone humanly possible. The reason your underlings are called mortals is that they die. With your help, they die a lot quicker. With all the evil power in the universe, you know what to do.", - "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/halloween/spookyrating/devil.jpeg" + "image": "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/holidays/halloween/spookyrating/devil.jpeg" } } diff --git a/bot/utils/extensions.py b/bot/utils/extensions.py index cbb8f15e..09192ae2 100644 --- a/bot/utils/extensions.py +++ b/bot/utils/extensions.py @@ -36,7 +36,7 @@ def walk_extensions() -> Iterator[str]: async def invoke_help_command(ctx: Context) -> None: """Invoke the help command or default help command if help extensions is not loaded.""" - if "bot.exts.evergreen.help" in ctx.bot.extensions: + if "bot.exts.core.help" in ctx.bot.extensions: help_command = ctx.bot.get_command("help") await ctx.invoke(help_command, ctx.command.qualified_name) return diff --git a/bot/utils/halloween/spookifications.py b/bot/utils/halloween/spookifications.py index f69dd6fd..93c5ddb9 100644 --- a/bot/utils/halloween/spookifications.py +++ b/bot/utils/halloween/spookifications.py @@ -22,7 +22,7 @@ def pentagram(im: Image) -> Image: """Adds pentagram to the image.""" im = im.convert("RGB") wt, ht = im.size - penta = Image.open("bot/resources/halloween/bloody-pentagram.png") + penta = Image.open("bot/resources/holidays/halloween/bloody-pentagram.png") penta = penta.resize((wt, ht)) im.paste(penta, (0, 0), penta) return im @@ -37,7 +37,7 @@ def bat(im: Image) -> Image: """ im = im.convert("RGB") wt, ht = im.size - bat = Image.open("bot/resources/halloween/bat-clipart.png") + bat = Image.open("bot/resources/holidays/halloween/bat-clipart.png") bat_size = randint(wt//10, wt//7) rot = randint(0, 90) bat = bat.resize((bat_size, bat_size)) -- cgit v1.2.3