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