diff options
Diffstat (limited to 'bot/seasons')
67 files changed, 0 insertions, 9299 deletions
diff --git a/bot/seasons/__init__.py b/bot/seasons/__init__.py deleted file mode 100644 index 7faf9164..00000000 --- a/bot/seasons/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -import logging - -from discord.ext import commands - -from bot.seasons.season import SeasonBase, SeasonManager, get_season - -__all__ = ("SeasonBase", "get_season") - -log = logging.getLogger(__name__) - - -def setup(bot: commands.Bot) -> None: - bot.add_cog(SeasonManager(bot)) - log.info("SeasonManager cog loaded") diff --git a/bot/seasons/christmas/__init__.py b/bot/seasons/christmas/__init__.py deleted file mode 100644 index 4287efb7..00000000 --- a/bot/seasons/christmas/__init__.py +++ /dev/null @@ -1,33 +0,0 @@ -import datetime - -from bot.constants import Colours -from bot.seasons import SeasonBase - - -class Christmas(SeasonBase): - """ - Christmas seasonal event attributes. - - We are getting into the festive spirit with a new server icon, new bot name and avatar, and some - new commands for you to check out! - - No matter who you are, where you are or what beliefs you may follow, we hope every one of you - enjoy this festive season! - """ - - name = "christmas" - bot_name = "Merrybot" - greeting = "Happy Holidays!" - - start_date = "01/12" - end_date = "01/01" - - colour = Colours.dark_green - icon = ( - "/logos/logo_seasonal/christmas/2019/festive_512.gif", - ) - - @classmethod - def end(cls) -> datetime.datetime: - """Overload the `SeasonBase` method to account for the event ending in the next year.""" - return datetime.datetime.strptime(f"{cls.end_date}/{cls.current_year() + 1}", cls.date_format) diff --git a/bot/seasons/christmas/adventofcode.py b/bot/seasons/christmas/adventofcode.py deleted file mode 100644 index 8caf43bd..00000000 --- a/bot/seasons/christmas/adventofcode.py +++ /dev/null @@ -1,742 +0,0 @@ -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, Tokens, WHITELISTED_CHANNELS -from bot.decorators import override_in_channel -from bot.utils import unlocked_role - -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) - - @commands.group(name="adventofcode", aliases=("aoc",), invoke_without_command=True) - @override_in_channel(AOC_WHITELIST) - async def adventofcode_group(self, ctx: commands.Context) -> None: - """All of the Advent of Code commands.""" - 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)) - log.info("AdventOfCode cog loaded") diff --git a/bot/seasons/christmas/hanukkah_embed.py b/bot/seasons/christmas/hanukkah_embed.py deleted file mode 100644 index aaa02b27..00000000 --- a/bot/seasons/christmas/hanukkah_embed.py +++ /dev/null @@ -1,113 +0,0 @@ -import datetime -import logging -from typing import List - -from discord import Embed -from discord.ext import commands - -from bot.constants import Colours - - -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 - - @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)) - log.info("Hanukkah embed cog loaded") diff --git a/bot/seasons/easter/__init__.py b/bot/seasons/easter/__init__.py deleted file mode 100644 index dd60bf5c..00000000 --- a/bot/seasons/easter/__init__.py +++ /dev/null @@ -1,35 +0,0 @@ -from bot.constants import Colours -from bot.seasons import SeasonBase - - -class Easter(SeasonBase): - """ - Here at Python Discord, we celebrate our version of Easter during the entire month of April. - - While this celebration takes place, you'll notice a few changes: - - • The server icon has changed to our Easter icon. Thanks to <@140605665772175361> for the - design! - - • [Easter issues now available for SeasonalBot on the repo](https://git.io/fjkvQ). - - • You may see stuff like an Easter themed esoteric challenge, a celebration of Earth Day, or - Easter-related micro-events for you to join. Stay tuned! - - If you'd like to contribute, head on over to <#635950537262759947> and we will help you get - started. It doesn't matter if you're new to open source or Python, if you'd like to help, we - will find you a task and teach you what you need to know. - """ - - name = "easter" - bot_name = "BunnyBot" - greeting = "Happy Easter!" - - # Duration of season - start_date = "02/04" - end_date = "30/04" - - colour = Colours.pink - icon = ( - "/logos/logo_seasonal/easter/easter.png", - ) diff --git a/bot/seasons/easter/april_fools_vids.py b/bot/seasons/easter/april_fools_vids.py deleted file mode 100644 index 4869f510..00000000 --- a/bot/seasons/easter/april_fools_vids.py +++ /dev/null @@ -1,39 +0,0 @@ -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)) - log.info('April Fools videos cog loaded!') diff --git a/bot/seasons/easter/avatar_easterifier.py b/bot/seasons/easter/avatar_easterifier.py deleted file mode 100644 index e21e35fc..00000000 --- a/bot/seasons/easter/avatar_easterifier.py +++ /dev/null @@ -1,129 +0,0 @@ -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)) - log.info("AvatarEasterifier cog loaded") diff --git a/bot/seasons/easter/bunny_name_generator.py b/bot/seasons/easter/bunny_name_generator.py deleted file mode 100644 index 97c467e1..00000000 --- a/bot/seasons/easter/bunny_name_generator.py +++ /dev/null @@ -1,93 +0,0 @@ -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)) - log.info("BunnyNameGenerator cog loaded.") diff --git a/bot/seasons/easter/conversationstarters.py b/bot/seasons/easter/conversationstarters.py deleted file mode 100644 index 3f38ae82..00000000 --- a/bot/seasons/easter/conversationstarters.py +++ /dev/null @@ -1,29 +0,0 @@ -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)) - log.info("ConvoStarters cog loaded") diff --git a/bot/seasons/easter/easter_riddle.py b/bot/seasons/easter/easter_riddle.py deleted file mode 100644 index f5b1aac7..00000000 --- a/bot/seasons/easter/easter_riddle.py +++ /dev/null @@ -1,101 +0,0 @@ -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)) - log.info("Easter Riddle bot loaded") diff --git a/bot/seasons/easter/egg_decorating.py b/bot/seasons/easter/egg_decorating.py deleted file mode 100644 index 23df95f1..00000000 --- a/bot/seasons/easter/egg_decorating.py +++ /dev/null @@ -1,119 +0,0 @@ -import json -import logging -import random -from contextlib import suppress -from io import BytesIO -from pathlib import Path -from typing import 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)) - log.info("EggDecorating cog loaded.") diff --git a/bot/seasons/easter/egg_facts.py b/bot/seasons/easter/egg_facts.py deleted file mode 100644 index e66e25a3..00000000 --- a/bot/seasons/easter/egg_facts.py +++ /dev/null @@ -1,62 +0,0 @@ -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 Channels -from bot.constants import Colours - - -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() - - @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) - - async def send_egg_fact_daily(self) -> None: - """A background task that sends an easter egg fact in the event channel everyday.""" - channel = self.bot.get_channel(Channels.seasonalbot_commands) - while True: - embed = self.make_embed() - await channel.send(embed=embed) - await asyncio.sleep(24 * 60 * 60) - - @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.loop.create_task(EasterFacts(bot).send_egg_fact_daily()) - bot.add_cog(EasterFacts(bot)) - log.info("EasterFacts cog loaded") diff --git a/bot/seasons/easter/egghead_quiz.py b/bot/seasons/easter/egghead_quiz.py deleted file mode 100644 index bd179fe2..00000000 --- a/bot/seasons/easter/egghead_quiz.py +++ /dev/null @@ -1,120 +0,0 @@ -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)) - log.info("EggheadQuiz bot loaded") diff --git a/bot/seasons/easter/traditions.py b/bot/seasons/easter/traditions.py deleted file mode 100644 index 9529823f..00000000 --- a/bot/seasons/easter/traditions.py +++ /dev/null @@ -1,31 +0,0 @@ -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)) - log.info("Traditions cog loaded") diff --git a/bot/seasons/evergreen/8bitify.py b/bot/seasons/evergreen/8bitify.py deleted file mode 100644 index 60062fc1..00000000 --- a/bot/seasons/evergreen/8bitify.py +++ /dev/null @@ -1,54 +0,0 @@ -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/seasons/evergreen/__init__.py b/bot/seasons/evergreen/__init__.py deleted file mode 100644 index b3d0dc63..00000000 --- a/bot/seasons/evergreen/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -from bot.seasons import SeasonBase - - -class Evergreen(SeasonBase): - """Evergreen Seasonal event attributes.""" - - bot_icon = "/logos/logo_seasonal/evergreen/logo_evergreen.png" - icon = ( - "/logos/logo_animated/heartbeat/heartbeat_512.gif", - "/logos/logo_animated/spinner/spinner_512.gif", - "/logos/logo_animated/tongues/tongues_512.gif", - "/logos/logo_animated/winky/winky_512.gif", - "/logos/logo_animated/jumper/jumper_512.gif", - "/logos/logo_animated/apple/apple_512.gif", - "/logos/logo_animated/blinky/blinky_512.gif", - "/logos/logo_animated/runner/runner_512.gif", - ) diff --git a/bot/seasons/evergreen/battleship.py b/bot/seasons/evergreen/battleship.py deleted file mode 100644 index 9b8aaa48..00000000 --- a/bot/seasons/evergreen/battleship.py +++ /dev/null @@ -1,444 +0,0 @@ -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)) - log.info("Battleship cog loaded") diff --git a/bot/seasons/evergreen/bookmark.py b/bot/seasons/evergreen/bookmark.py deleted file mode 100644 index bd7d5c11..00000000 --- a/bot/seasons/evergreen/bookmark.py +++ /dev/null @@ -1,65 +0,0 @@ -import logging -import random - -import discord -from discord.ext import commands - -from bot.constants import Colours, ERROR_REPLIES, Emojis, bookmark_icon_url - -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=bookmark_icon_url) - - 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)) - log.info("Bookmark cog loaded") diff --git a/bot/seasons/evergreen/error_handler.py b/bot/seasons/evergreen/error_handler.py deleted file mode 100644 index 2753a6df..00000000 --- a/bot/seasons/evergreen/error_handler.py +++ /dev/null @@ -1,125 +0,0 @@ -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.decorators import InChannelCheckFailure - -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, InChannelCheckFailure): - 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)) - log.info("CommandErrorHandler cog loaded") diff --git a/bot/seasons/evergreen/fun.py b/bot/seasons/evergreen/fun.py deleted file mode 100644 index 889ae079..00000000 --- a/bot/seasons/evergreen/fun.py +++ /dev/null @@ -1,148 +0,0 @@ -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)) - log.info("Fun cog loaded") diff --git a/bot/seasons/evergreen/game.py b/bot/seasons/evergreen/game.py deleted file mode 100644 index e6700937..00000000 --- a/bot/seasons/evergreen/game.py +++ /dev/null @@ -1,395 +0,0 @@ -import difflib -import logging -import random -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.decorators import with_role -from bot.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__) - -# --------- -# 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=1.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 = "`, `".join(difflib.get_close_matches(genre, self.genres)) - await ctx.send(f"Invalid genre `{genre}`. {f'Maybe you meant `{possibilities}`?' if possibilities else ''}") - 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 - - -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/seasons/evergreen/issues.py b/bot/seasons/evergreen/issues.py deleted file mode 100644 index fba5b174..00000000 --- a/bot/seasons/evergreen/issues.py +++ /dev/null @@ -1,77 +0,0 @@ -import logging - -import discord -from discord.ext import commands - -from bot.constants import Colours, Emojis, WHITELISTED_CHANNELS -from bot.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)) - log.info("Issues cog loaded") diff --git a/bot/seasons/evergreen/magic_8ball.py b/bot/seasons/evergreen/magic_8ball.py deleted file mode 100644 index e47ef454..00000000 --- a/bot/seasons/evergreen/magic_8ball.py +++ /dev/null @@ -1,32 +0,0 @@ -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)) - log.info("Magic8ball cog loaded") diff --git a/bot/seasons/evergreen/minesweeper.py b/bot/seasons/evergreen/minesweeper.py deleted file mode 100644 index b0ba8145..00000000 --- a/bot/seasons/evergreen/minesweeper.py +++ /dev/null @@ -1,285 +0,0 @@ -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)) - log.info("Minesweeper cog loaded") diff --git a/bot/seasons/evergreen/movie.py b/bot/seasons/evergreen/movie.py deleted file mode 100644 index 3c5a312d..00000000 --- a/bot/seasons/evergreen/movie.py +++ /dev/null @@ -1,198 +0,0 @@ -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.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/seasons/evergreen/recommend_game.py b/bot/seasons/evergreen/recommend_game.py deleted file mode 100644 index 835a4e53..00000000 --- a/bot/seasons/evergreen/recommend_game.py +++ /dev/null @@ -1,51 +0,0 @@ -import json -import logging -from pathlib import Path -from random import shuffle - -import discord -from discord.ext import commands - -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)) - log.info("RecommendGame cog loaded") diff --git a/bot/seasons/evergreen/reddit.py b/bot/seasons/evergreen/reddit.py deleted file mode 100644 index 32ca419a..00000000 --- a/bot/seasons/evergreen/reddit.py +++ /dev/null @@ -1,130 +0,0 @@ -import logging -import random - -import discord -from discord.ext import commands -from discord.ext.commands.cooldowns import BucketType - -from bot.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)) - log.debug('Loaded') diff --git a/bot/seasons/evergreen/showprojects.py b/bot/seasons/evergreen/showprojects.py deleted file mode 100644 index a943e548..00000000 --- a/bot/seasons/evergreen/showprojects.py +++ /dev/null @@ -1,34 +0,0 @@ -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)) - log.info("ShowProjects cog loaded") diff --git a/bot/seasons/evergreen/snakes/__init__.py b/bot/seasons/evergreen/snakes/__init__.py deleted file mode 100644 index d7f9f20c..00000000 --- a/bot/seasons/evergreen/snakes/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -import logging - -from discord.ext import commands - -from bot.seasons.evergreen.snakes.snakes_cog import Snakes - -log = logging.getLogger(__name__) - - -def setup(bot: commands.Bot) -> None: - """Snakes Cog load.""" - bot.add_cog(Snakes(bot)) - log.info("Snakes cog loaded") diff --git a/bot/seasons/evergreen/snakes/converter.py b/bot/seasons/evergreen/snakes/converter.py deleted file mode 100644 index 57103b57..00000000 --- a/bot/seasons/evergreen/snakes/converter.py +++ /dev/null @@ -1,85 +0,0 @@ -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.seasons.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/seasons/evergreen/snakes/snakes_cog.py b/bot/seasons/evergreen/snakes/snakes_cog.py deleted file mode 100644 index 09f5e250..00000000 --- a/bot/seasons/evergreen/snakes/snakes_cog.py +++ /dev/null @@ -1,1149 +0,0 @@ -import asyncio -import colorsys -import logging -import os -import random -import re -import string -import textwrap -import urllib -from functools import partial -from io import BytesIO -from typing import Any, 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.decorators import locked -from bot.seasons.evergreen.snakes import utils -from bot.seasons.evergreen.snakes.converter import Snake - -log = logging.getLogger(__name__) - - -# region: Constants -# Color -SNAKE_COLOR = 0x399600 - -# Antidote constants -SYRINGE_EMOJI = "\U0001F489" # :syringe: -PILL_EMOJI = "\U0001F48A" # :pill: -HOURGLASS_EMOJI = "\u231B" # :hourglass: -CROSSBONES_EMOJI = "\u2620" # :skull_crossbones: -ALEMBIC_EMOJI = "\u2697" # :alembic: -TICK_EMOJI = "\u2705" # :white_check_mark: - Correct peg, correct hole -CROSS_EMOJI = "\u274C" # :x: - Wrong peg, wrong hole -BLANK_EMOJI = "\u26AA" # :white_circle: - Correct peg, wrong hole -HOLE_EMOJI = "\u2B1C" # :white_square: - Used in guesses -EMPTY_UNICODE = "\u200b" # literally just an empty space - -ANTIDOTE_EMOJI = ( - SYRINGE_EMOJI, - PILL_EMOJI, - HOURGLASS_EMOJI, - CROSSBONES_EMOJI, - ALEMBIC_EMOJI, -) - -# Quiz constants -ANSWERS_EMOJI = { - "a": "\U0001F1E6", # :regional_indicator_a: 🇦 - "b": "\U0001F1E7", # :regional_indicator_b: 🇧 - "c": "\U0001F1E8", # :regional_indicator_c: 🇨 - "d": "\U0001F1E9", # :regional_indicator_d: 🇩 -} - -ANSWERS_EMOJI_REVERSE = { - "\U0001F1E6": "A", # :regional_indicator_a: 🇦 - "\U0001F1E7": "B", # :regional_indicator_b: 🇧 - "\U0001F1E8": "C", # :regional_indicator_c: 🇨 - "\U0001F1E9": "D", # :regional_indicator_d: 🇩 -} - -# Zzzen of pythhhon constant -ZEN = """ -Beautiful is better than ugly. -Explicit is better than implicit. -Simple is better than complex. -Complex is better than complicated. -Flat is better than nested. -Sparse is better than dense. -Readability counts. -Special cases aren't special enough to break the rules. -Although practicality beats purity. -Errors should never pass silently. -Unless explicitly silenced. -In the face of ambiguity, refuse the temptation to guess. -There should be one-- and preferably only one --obvious way to do it. -Now is better than never. -Although never is often better than *right* now. -If the implementation is hard to explain, it's a bad idea. -If the implementation is easy to explain, it may be a good idea. -""" - -# Max messages to train snake_chat on -MSG_MAX = 100 - -# get_snek constants -URL = "https://en.wikipedia.org/w/api.php?" - -# snake guess responses -INCORRECT_GUESS = ( - "Nope, that's not what it is.", - "Not quite.", - "Not even close.", - "Terrible guess.", - "Nnnno.", - "Dude. No.", - "I thought everyone knew this one.", - "Guess you suck at snakes.", - "Bet you feel stupid now.", - "Hahahaha, no.", - "Did you hit the wrong key?" -) - -CORRECT_GUESS = ( - "**WRONG**. Wait, no, actually you're right.", - "Yeah, you got it!", - "Yep, that's exactly what it is.", - "Uh-huh. Yep yep yep.", - "Yeah that's right.", - "Yup. How did you know that?", - "Are you a herpetologist?", - "Sure, okay, but I bet you can't pronounce it.", - "Are you cheating?" -) - -# snake card consts -CARD = { - "top": Image.open("bot/resources/snakes/snake_cards/card_top.png"), - "frame": Image.open("bot/resources/snakes/snake_cards/card_frame.png"), - "bottom": Image.open("bot/resources/snakes/snake_cards/card_bottom.png"), - "backs": [ - Image.open(f"bot/resources/snakes/snake_cards/backs/{file}") - for file in os.listdir("bot/resources/snakes/snake_cards/backs") - ], - "font": ImageFont.truetype("bot/resources/snakes/snake_cards/expressway.ttf", 20) -} -# endregion - - -class Snakes(Cog): - """ - Commands related to snakes, 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/seasons/evergreen/snakes/utils.py b/bot/seasons/evergreen/snakes/utils.py deleted file mode 100644 index 7d6caf04..00000000 --- a/bot/seasons/evergreen/snakes/utils.py +++ /dev/null @@ -1,716 +0,0 @@ -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/seasons/evergreen/speedrun.py b/bot/seasons/evergreen/speedrun.py deleted file mode 100644 index 76c5e8d3..00000000 --- a/bot/seasons/evergreen/speedrun.py +++ /dev/null @@ -1,28 +0,0 @@ -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)) - log.info("Speedrun cog loaded") diff --git a/bot/seasons/evergreen/trivia_quiz.py b/bot/seasons/evergreen/trivia_quiz.py deleted file mode 100644 index 99b64497..00000000 --- a/bot/seasons/evergreen/trivia_quiz.py +++ /dev/null @@ -1,303 +0,0 @@ -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)) - logger.debug("TriviaQuiz cog loaded") diff --git a/bot/seasons/evergreen/uptime.py b/bot/seasons/evergreen/uptime.py deleted file mode 100644 index 6f24f545..00000000 --- a/bot/seasons/evergreen/uptime.py +++ /dev/null @@ -1,34 +0,0 @@ -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)) - log.info("Uptime cog loaded") diff --git a/bot/seasons/halloween/8ball.py b/bot/seasons/halloween/8ball.py deleted file mode 100644 index 2e1c2804..00000000 --- a/bot/seasons/halloween/8ball.py +++ /dev/null @@ -1,34 +0,0 @@ -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)) - log.info("SpookyEightBall cog loaded") diff --git a/bot/seasons/halloween/__init__.py b/bot/seasons/halloween/__init__.py deleted file mode 100644 index c81879d7..00000000 --- a/bot/seasons/halloween/__init__.py +++ /dev/null @@ -1,24 +0,0 @@ -from bot.constants import Colours -from bot.seasons import SeasonBase - - -class Halloween(SeasonBase): - """ - Halloween Seasonal event attributes. - - Announcement for this cog temporarily disabled, since we're doing a custom - Hacktoberfest announcement. If you're enabling the announcement again, - make sure to update this docstring accordingly. - """ - - name = "halloween" - bot_name = "NeonBot" - greeting = "Happy Halloween!" - - start_date = "01/10" - end_date = "01/11" - - colour = Colours.pink - icon = ( - "/logos/logo_seasonal/hacktober/hacktoberfest.png", - ) diff --git a/bot/seasons/halloween/candy_collection.py b/bot/seasons/halloween/candy_collection.py deleted file mode 100644 index 490609dd..00000000 --- a/bot/seasons/halloween/candy_collection.py +++ /dev/null @@ -1,221 +0,0 @@ -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 - -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 - - @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}') - - @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) - - @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)) - log.info("CandyCollection cog loaded") diff --git a/bot/seasons/halloween/hacktober-issue-finder.py b/bot/seasons/halloween/hacktober-issue-finder.py deleted file mode 100644 index 10732374..00000000 --- a/bot/seasons/halloween/hacktober-issue-finder.py +++ /dev/null @@ -1,107 +0,0 @@ -import datetime -import logging -import random -from typing import Dict, Optional - -import aiohttp -import discord -from discord.ext import commands - -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) - - @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)) - log.info("hacktober-issue-finder cog loaded") diff --git a/bot/seasons/halloween/hacktoberstats.py b/bot/seasons/halloween/hacktoberstats.py deleted file mode 100644 index d61e048b..00000000 --- a/bot/seasons/halloween/hacktoberstats.py +++ /dev/null @@ -1,340 +0,0 @@ -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, WHITELISTED_CHANNELS -from bot.decorators import 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() - - @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) - - @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") - - @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)) - log.info("HacktoberStats cog loaded") diff --git a/bot/seasons/halloween/halloween_facts.py b/bot/seasons/halloween/halloween_facts.py deleted file mode 100644 index 94730d9e..00000000 --- a/bot/seasons/halloween/halloween_facts.py +++ /dev/null @@ -1,68 +0,0 @@ -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 - -from bot.constants import Channels - -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.channel = None - self.facts = list(enumerate(self.halloween_facts)) - random.shuffle(self.facts) - - @commands.Cog.listener() - async def on_ready(self) -> None: - """Get event Channel object and initialize fact task loop.""" - self.channel = self.bot.get_channel(Channels.seasonalbot_commands) - self.bot.loop.create_task(self._fact_publisher_task()) - - 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)) - log.info("HalloweenFacts cog loaded") diff --git a/bot/seasons/halloween/halloweenify.py b/bot/seasons/halloween/halloweenify.py deleted file mode 100644 index dfcc2b1e..00000000 --- a/bot/seasons/halloween/halloweenify.py +++ /dev/null @@ -1,52 +0,0 @@ -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)) - log.info("Halloweenify cog loaded") diff --git a/bot/seasons/halloween/monsterbio.py b/bot/seasons/halloween/monsterbio.py deleted file mode 100644 index bfa8a026..00000000 --- a/bot/seasons/halloween/monsterbio.py +++ /dev/null @@ -1,56 +0,0 @@ -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)) - log.info("MonsterBio cog loaded.") diff --git a/bot/seasons/halloween/monstersurvey.py b/bot/seasons/halloween/monstersurvey.py deleted file mode 100644 index 12e1d022..00000000 --- a/bot/seasons/halloween/monstersurvey.py +++ /dev/null @@ -1,206 +0,0 @@ -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)) - log.info("MonsterSurvey cog loaded") diff --git a/bot/seasons/halloween/scarymovie.py b/bot/seasons/halloween/scarymovie.py deleted file mode 100644 index 3823a3e4..00000000 --- a/bot/seasons/halloween/scarymovie.py +++ /dev/null @@ -1,132 +0,0 @@ -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)) - log.info("ScaryMovie cog loaded") diff --git a/bot/seasons/halloween/spookyavatar.py b/bot/seasons/halloween/spookyavatar.py deleted file mode 100644 index 268de3fb..00000000 --- a/bot/seasons/halloween/spookyavatar.py +++ /dev/null @@ -1,53 +0,0 @@ -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)) - log.info("SpookyAvatar cog loaded") diff --git a/bot/seasons/halloween/spookygif.py b/bot/seasons/halloween/spookygif.py deleted file mode 100644 index 818de8cd..00000000 --- a/bot/seasons/halloween/spookygif.py +++ /dev/null @@ -1,39 +0,0 @@ -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)) - log.info("SpookyGif cog loaded") diff --git a/bot/seasons/halloween/spookyrating.py b/bot/seasons/halloween/spookyrating.py deleted file mode 100644 index 7f78f536..00000000 --- a/bot/seasons/halloween/spookyrating.py +++ /dev/null @@ -1,67 +0,0 @@ -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)) - log.info("SpookyRating cog loaded") diff --git a/bot/seasons/halloween/spookyreact.py b/bot/seasons/halloween/spookyreact.py deleted file mode 100644 index 90b1254d..00000000 --- a/bot/seasons/halloween/spookyreact.py +++ /dev/null @@ -1,72 +0,0 @@ -import logging -import re - -import discord -from discord.ext.commands import Bot, Cog - -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 - - @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)) - log.info("SpookyReact cog loaded") diff --git a/bot/seasons/halloween/spookysound.py b/bot/seasons/halloween/spookysound.py deleted file mode 100644 index e0676d0a..00000000 --- a/bot/seasons/halloween/spookysound.py +++ /dev/null @@ -1,48 +0,0 @@ -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)) - log.info("SpookySound cog loaded") diff --git a/bot/seasons/halloween/timeleft.py b/bot/seasons/halloween/timeleft.py deleted file mode 100644 index 8cb3f4f6..00000000 --- a/bot/seasons/halloween/timeleft.py +++ /dev/null @@ -1,60 +0,0 @@ -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)) - log.info("TimeLeft cog loaded") diff --git a/bot/seasons/pride/__init__.py b/bot/seasons/pride/__init__.py deleted file mode 100644 index 08df2fa1..00000000 --- a/bot/seasons/pride/__init__.py +++ /dev/null @@ -1,36 +0,0 @@ -from bot.constants import Colours -from bot.seasons import SeasonBase - - -class Pride(SeasonBase): - """ - The month of June is a special month for us at Python Discord. - - It is very important to us that everyone feels welcome here, no matter their origin, - identity or sexuality. During the month of June, while some of you are participating in Pride - festivals across the world, we will be celebrating individuality and commemorating the history - and challenges of the LGBTQ+ community with a Pride event of our own! - - While this celebration takes place, you'll notice a few changes: - • The server icon has changed to our Pride icon. Thanks to <@98694745760481280> for the design! - • [Pride issues are now available for SeasonalBot on the repo](https://git.io/pythonpride). - • You may see Pride-themed esoteric challenges and other microevents. - - If you'd like to contribute, head on over to <#635950537262759947> and we will help you get - started. It doesn't matter if you're new to open source or Python, if you'd like to help, we - will find you a task and teach you what you need to know. - """ - - name = "pride" - bot_name = "ProudBot" - greeting = "Happy Pride Month!" - - # Duration of season - start_date = "01/06" - end_date = "01/07" - - # Season logo - colour = Colours.soft_red - icon = ( - "/logos/logo_seasonal/pride/logo_pride.png", - ) diff --git a/bot/seasons/pride/drag_queen_name.py b/bot/seasons/pride/drag_queen_name.py deleted file mode 100644 index 43813fbd..00000000 --- a/bot/seasons/pride/drag_queen_name.py +++ /dev/null @@ -1,33 +0,0 @@ -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)) - log.info("Drag queen name generator cog loaded!") diff --git a/bot/seasons/pride/pride_anthem.py b/bot/seasons/pride/pride_anthem.py deleted file mode 100644 index b0c6d34e..00000000 --- a/bot/seasons/pride/pride_anthem.py +++ /dev/null @@ -1,58 +0,0 @@ -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)) - log.info("Pride anthems cog loaded!") diff --git a/bot/seasons/pride/pride_avatar.py b/bot/seasons/pride/pride_avatar.py deleted file mode 100644 index 85e49d5c..00000000 --- a/bot/seasons/pride/pride_avatar.py +++ /dev/null @@ -1,145 +0,0 @@ -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)) - log.info("PrideAvatar cog loaded") diff --git a/bot/seasons/pride/pride_facts.py b/bot/seasons/pride/pride_facts.py deleted file mode 100644 index 5c19dfd0..00000000 --- a/bot/seasons/pride/pride_facts.py +++ /dev/null @@ -1,106 +0,0 @@ -import asyncio -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 -from bot.constants import Colours - -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() - - @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) - - async def send_pride_fact_daily(self) -> None: - """Background task to post the daily pride fact every day.""" - channel = self.bot.get_channel(Channels.seasonalbot_commands) - while True: - await self.send_select_fact(channel, datetime.utcnow()) - await asyncio.sleep(24 * 60 * 60) - - 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.loop.create_task(PrideFacts(bot).send_pride_fact_daily()) - bot.add_cog(PrideFacts(bot)) - log.info("Pride facts cog loaded!") diff --git a/bot/seasons/season.py b/bot/seasons/season.py deleted file mode 100644 index 763a08d2..00000000 --- a/bot/seasons/season.py +++ /dev/null @@ -1,560 +0,0 @@ -import asyncio -import contextlib -import datetime -import importlib -import inspect -import logging -import pkgutil -from pathlib import Path -from typing import List, Optional, Tuple, Type, Union - -import async_timeout -import discord -from discord.ext import commands - -from bot.bot import bot -from bot.constants import Channels, Client, Roles -from bot.decorators import with_role - -log = logging.getLogger(__name__) - -ICON_BASE_URL = "https://raw.githubusercontent.com/python-discord/branding/master" - - -def get_seasons() -> List[str]: - """Returns all the Season objects located in /bot/seasons/.""" - seasons = [] - - for module in pkgutil.iter_modules([Path("bot/seasons")]): - if module.ispkg: - seasons.append(module.name) - return seasons - - -def get_season_class(season_name: str) -> Type["SeasonBase"]: - """Gets the season class of the season module.""" - season_lib = importlib.import_module(f"bot.seasons.{season_name}") - class_name = season_name.replace("_", " ").title().replace(" ", "") - return getattr(season_lib, class_name) - - -def get_season(season_name: str = None, date: datetime.datetime = None) -> "SeasonBase": - """Returns a Season object based on either a string or a date.""" - # If either both or neither are set, raise an error. - if not bool(season_name) ^ bool(date): - raise UserWarning("This function requires either a season or a date in order to run.") - - seasons = get_seasons() - - # Use season override if season name not provided - if not season_name and Client.season_override: - log.debug(f"Season override found: {Client.season_override}") - season_name = Client.season_override - - # If name provided grab the specified class or fallback to evergreen. - if season_name: - season_name = season_name.lower() - if season_name not in seasons: - season_name = "evergreen" - season_class = get_season_class(season_name) - return season_class() - - # If not, we have to figure out if the date matches any of the seasons. - seasons.remove("evergreen") - for season_name in seasons: - season_class = get_season_class(season_name) - # check if date matches before returning an instance - if season_class.is_between_dates(date): - return season_class() - else: - evergreen_class = get_season_class("evergreen") - return evergreen_class() - - -class SeasonBase: - """Base class for Seasonal classes.""" - - name: Optional[str] = "evergreen" - bot_name: str = "SeasonalBot" - - start_date: Optional[str] = None - end_date: Optional[str] = None - should_announce: bool = False - - colour: Optional[int] = None - icon: Tuple[str, ...] = ("/logos/logo_full/logo_full.png",) - bot_icon: Optional[str] = None - - date_format: str = "%d/%m/%Y" - - index: int = 0 - - @staticmethod - def current_year() -> int: - """Returns the current year.""" - return datetime.date.today().year - - @classmethod - def start(cls) -> datetime.datetime: - """ - Returns the start date using current year and start_date attribute. - - If no start_date was defined, returns the minimum datetime to ensure it's always below checked dates. - """ - if not cls.start_date: - return datetime.datetime.min - return datetime.datetime.strptime(f"{cls.start_date}/{cls.current_year()}", cls.date_format) - - @classmethod - def end(cls) -> datetime.datetime: - """ - Returns the start date using current year and end_date attribute. - - If no end_date was defined, returns the minimum datetime to ensure it's always above checked dates. - """ - if not cls.end_date: - return datetime.datetime.max - return datetime.datetime.strptime(f"{cls.end_date}/{cls.current_year()}", cls.date_format) - - @classmethod - def is_between_dates(cls, date: datetime.datetime) -> bool: - """Determines if the given date falls between the season's date range.""" - return cls.start() <= date <= cls.end() - - @property - def name_clean(self) -> str: - """Return the Season's name with underscores replaced by whitespace.""" - return self.name.replace("_", " ").title() - - @property - def greeting(self) -> str: - """ - Provides a default greeting based on the season name if one wasn't defined in the season class. - - It's recommended to define one in most cases by overwriting this as a normal attribute in the - inheriting class. - """ - return f"New Season, {self.name_clean}!" - - async def get_icon(self, avatar: bool = False, index: int = 0) -> Tuple[bytes, str]: - """ - Retrieve the season's icon from the branding repository using the Season's icon attribute. - - This also returns the relative URL path for logging purposes - If `avatar` is True, uses optional bot-only avatar icon if present. - Returns the data for the given `index`, defaulting to the first item. - - The icon attribute must provide the url path, starting from the master branch base url, - including the starting slash. - e.g. `/logos/logo_seasonal/valentines/loved_up.png` - """ - icon = self.icon[index] - if avatar and self.bot_icon: - icon = self.bot_icon - - full_url = ICON_BASE_URL + icon - log.debug(f"Getting icon from: {full_url}") - async with bot.http_session.get(full_url) as resp: - return (await resp.read(), icon) - - async def apply_username(self, *, debug: bool = False) -> Union[bool, None]: - """ - Applies the username for the current season. - - Only changes nickname if `bool` is False, otherwise only changes the nickname. - - Returns True if it successfully changed the username. - Returns False if it failed to change the username, falling back to nick. - Returns None if `debug` was True and username change wasn't attempted. - """ - guild = bot.get_guild(Client.guild) - result = None - - # Change only nickname if in debug mode due to ratelimits for user edits - if debug: - if guild.me.display_name != self.bot_name: - log.debug(f"Changing nickname to {self.bot_name}") - await guild.me.edit(nick=self.bot_name) - - else: - if bot.user.name != self.bot_name: - # Attempt to change user details - log.debug(f"Changing username to {self.bot_name}") - with contextlib.suppress(discord.HTTPException): - await bot.user.edit(username=self.bot_name) - - # Fallback on nickname if failed due to ratelimit - if bot.user.name != self.bot_name: - log.warning(f"Username failed to change: Changing nickname to {self.bot_name}") - await guild.me.edit(nick=self.bot_name) - result = False - else: - result = True - - # Remove nickname if an old one exists - if guild.me.nick and guild.me.nick != self.bot_name: - log.debug(f"Clearing old nickname of {guild.me.nick}") - await guild.me.edit(nick=None) - - return result - - async def apply_avatar(self) -> bool: - """ - Applies the avatar for the current season. - - Returns True if successful. - """ - # Track old avatar hash for later comparison - old_avatar = bot.user.avatar - - # Attempt the change - icon, name = await self.get_icon(avatar=True) - log.debug(f"Changing avatar to {name}") - with contextlib.suppress(discord.HTTPException, asyncio.TimeoutError): - async with async_timeout.timeout(5): - await bot.user.edit(avatar=icon) - - if bot.user.avatar != old_avatar: - log.debug(f"Avatar changed to {name}") - return True - - log.warning(f"Changing avatar failed: {name}") - return False - - async def apply_server_icon(self) -> bool: - """ - Applies the server icon for the current season. - - Returns True if was successful. - """ - guild = bot.get_guild(Client.guild) - - # Track old icon hash for later comparison - old_icon = guild.icon - - # Attempt the change - - icon, name = await self.get_icon(index=self.index) - - log.debug(f"Changing server icon to {name}") - - with contextlib.suppress(discord.HTTPException, asyncio.TimeoutError): - async with async_timeout.timeout(5): - await guild.edit(icon=icon, reason=f"Seasonbot Season Change: {self.name}") - - new_icon = bot.get_guild(Client.guild).icon - if new_icon != old_icon: - log.debug(f"Server icon changed to {name}") - return True - - log.warning(f"Changing server icon failed: {name}") - return False - - async def change_server_icon(self) -> bool: - """ - Changes the server icon. - - This only has an effect when the Season's icon attribute is a list, in which it cycles through. - Returns True if was successful. - """ - if len(self.icon) == 1: - return - - self.index += 1 - self.index %= len(self.icon) - - return await self.apply_server_icon() - - async def announce_season(self) -> None: - """ - Announces a change in season in the announcement channel. - - Auto-announcement is configured by the `should_announce` `SeasonBase` attribute - """ - # Short circuit if the season had disabled automatic announcements - if not self.should_announce: - log.debug(f"Season changed without announcement: {self.name}") - return - - guild = bot.get_guild(Client.guild) - channel = guild.get_channel(Channels.announcements) - mention = f"<@&{Roles.announcements}>" - - # Build cog info output - doc = inspect.getdoc(self) - announce = "\n\n".join(l.replace("\n", " ") for l in doc.split("\n\n")) - - # No announcement message found - if not doc: - return - - embed = discord.Embed(description=f"{announce}\n\n", colour=self.colour or guild.me.colour) - embed.set_author(name=self.greeting) - - if self.icon: - embed.set_image(url=ICON_BASE_URL+self.icon[0]) - - # Find any seasonal commands - cogs = [] - for cog in bot.cogs.values(): - if "evergreen" in cog.__module__: - continue - cog_name = type(cog).__name__ - if cog_name != "SeasonManager": - cogs.append(cog_name) - - if cogs: - def cog_name(cog: commands.Cog) -> str: - return type(cog).__name__ - - cog_info = [] - for cog in sorted(cogs, key=cog_name): - doc = inspect.getdoc(bot.get_cog(cog)) - if doc: - cog_info.append(f"**{cog}**\n*{doc}*") - else: - cog_info.append(f"**{cog}**") - - cogs_text = "\n".join(cog_info) - embed.add_field(name="New Command Categories", value=cogs_text) - embed.set_footer(text="To see the new commands, use .help Category") - - await channel.send(mention, embed=embed) - - async def load(self) -> None: - """ - Loads extensions, bot name and avatar, server icon and announces new season. - - If in debug mode, the avatar, server icon, and announcement will be skipped. - """ - self.index = 0 - # Prepare all the seasonal cogs, and then the evergreen ones. - extensions = [] - for ext_folder in {self.name, "evergreen"}: - if ext_folder: - log.info(f"Start loading extensions from seasons/{ext_folder}/") - path = Path("bot/seasons") / ext_folder - for ext_name in [i[1] for i in pkgutil.iter_modules([path])]: - extensions.append(f"bot.seasons.{ext_folder}.{ext_name}") - - # Finally we can load all the cogs we've prepared. - bot.load_extensions(extensions) - - # Apply seasonal elements after extensions successfully load - username_changed = await self.apply_username(debug=Client.debug) - - # Avoid major changes and announcements if debug mode - if not Client.debug: - log.info("Applying avatar.") - await self.apply_avatar() - if username_changed: - log.info("Applying server icon.") - await self.apply_server_icon() - log.info(f"Announcing season {self.name}.") - await self.announce_season() - else: - log.info(f"Skipping server icon change due to username not being changed.") - log.info(f"Skipping season announcement due to username not being changed.") - - await bot.send_log("SeasonalBot Loaded!", f"Active Season: **{self.name_clean}**") - - -class SeasonManager(commands.Cog): - """A cog for managing seasons.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - self.season = get_season(date=datetime.datetime.utcnow()) - self.season_task = bot.loop.create_task(self.load_seasons()) - - # Figure out number of seconds until a minute past midnight - tomorrow = datetime.datetime.now() + datetime.timedelta(1) - midnight = datetime.datetime( - year=tomorrow.year, - month=tomorrow.month, - day=tomorrow.day, - hour=0, - minute=0, - second=0 - ) - self.sleep_time = (midnight - datetime.datetime.now()).seconds + 60 - - async def load_seasons(self) -> None: - """Asynchronous timer loop to check for a new season every midnight.""" - await self.bot.wait_until_ready() - await self.season.load() - days_since_icon_change = 0 - - while True: - await asyncio.sleep(self.sleep_time) # Sleep until midnight - self.sleep_time = 24 * 3600 # Next time, sleep for 24 hours - - days_since_icon_change += 1 - log.debug(f"Days since last icon change: {days_since_icon_change}") - - # If the season has changed, load it. - new_season = get_season(date=datetime.datetime.utcnow()) - if new_season.name != self.season.name: - self.season = new_season - await self.season.load() - days_since_icon_change = 0 # Start counting afresh for the new season - - # Otherwise we check whether it's time for an icon cycle within the current season - else: - if days_since_icon_change == Client.icon_cycle_frequency: - await self.season.change_server_icon() - days_since_icon_change = 0 - else: - log.debug(f"Waiting {Client.icon_cycle_frequency - days_since_icon_change} more days to cycle icon") - - @with_role(Roles.moderator, Roles.admin, Roles.owner) - @commands.command(name="season") - async def change_season(self, ctx: commands.Context, new_season: str) -> None: - """Changes the currently active season on the bot.""" - self.season = get_season(season_name=new_season) - await self.season.load() - await ctx.send(f"Season changed to {new_season}.") - - @with_role(Roles.moderator, Roles.admin, Roles.owner) - @commands.command(name="seasons") - async def show_seasons(self, ctx: commands.Context) -> None: - """Shows the available seasons and their dates.""" - # Sort by start order, followed by lower duration - def season_key(season_class: Type[SeasonBase]) -> Tuple[datetime.datetime, datetime.timedelta]: - return season_class.start(), season_class.end() - datetime.datetime.max - - current_season = self.season.name - - forced_space = "\u200b " - - entries = [] - seasons = [get_season_class(s) for s in get_seasons()] - for season in sorted(seasons, key=season_key): - start = season.start_date - end = season.end_date - if start and not end: - period = f"From {start}" - elif end and not start: - period = f"Until {end}" - elif not end and not start: - period = f"Always" - else: - period = f"{start} to {end}" - - # Bold period if current date matches season date range - is_current = season.is_between_dates(datetime.datetime.utcnow()) - pdec = "**" if is_current else "" - - # Underline currently active season - is_active = current_season == season.name - sdec = "__" if is_active else "" - - entries.append( - f"**{sdec}{season.__name__}:{sdec}**\n" - f"{forced_space*3}{pdec}{period}{pdec}\n" - ) - - embed = discord.Embed(description="\n".join(entries), colour=ctx.guild.me.colour) - embed.set_author(name="Seasons") - await ctx.send(embed=embed) - - @with_role(Roles.moderator, Roles.admin, Roles.owner) - @commands.group() - async def refresh(self, ctx: commands.Context) -> None: - """Refreshes certain seasonal elements without reloading seasons.""" - if not ctx.invoked_subcommand: - await ctx.send_help(ctx.command) - - @refresh.command(name="avatar") - async def refresh_avatar(self, ctx: commands.Context) -> None: - """Re-applies the bot avatar for the currently loaded season.""" - # Attempt the change - is_changed = await self.season.apply_avatar() - - if is_changed: - colour = ctx.guild.me.colour - title = "Avatar Refreshed" - else: - colour = discord.Colour.red() - title = "Avatar Failed to Refresh" - - # Report back details - season_name = type(self.season).__name__ - embed = discord.Embed( - description=f"**Season:** {season_name}\n**Avatar:** {self.season.bot_icon or self.season.icon}", - colour=colour - ) - embed.set_author(name=title) - embed.set_thumbnail(url=bot.user.avatar_url_as(format="png")) - await ctx.send(embed=embed) - - @refresh.command(name="icon") - async def refresh_server_icon(self, ctx: commands.Context) -> None: - """Re-applies the server icon for the currently loaded season.""" - # Attempt the change - is_changed = await self.season.apply_server_icon() - - if is_changed: - colour = ctx.guild.me.colour - title = "Server Icon Refreshed" - else: - colour = discord.Colour.red() - title = "Server Icon Failed to Refresh" - - # Report back details - season_name = type(self.season).__name__ - embed = discord.Embed( - description=f"**Season:** {season_name}\n**Icon:** {self.season.icon}", - colour=colour - ) - embed.set_author(name=title) - embed.set_thumbnail(url=bot.get_guild(Client.guild).icon_url_as(format="png")) - await ctx.send(embed=embed) - - @refresh.command(name="username", aliases=("name",)) - async def refresh_username(self, ctx: commands.Context) -> None: - """Re-applies the bot username for the currently loaded season.""" - old_username = str(bot.user) - old_display_name = ctx.guild.me.display_name - - # Attempt the change - is_changed = await self.season.apply_username() - - if is_changed: - colour = ctx.guild.me.colour - title = "Username Refreshed" - changed_element = "Username" - old_name = old_username - new_name = str(bot.user) - else: - colour = discord.Colour.red() - - # If None, it's because it wasn't meant to change username - if is_changed is None: - title = "Nickname Refreshed" - else: - title = "Username Failed to Refresh" - changed_element = "Nickname" - old_name = old_display_name - new_name = self.season.bot_name - - # Report back details - season_name = type(self.season).__name__ - embed = discord.Embed( - description=f"**Season:** {season_name}\n" - f"**Old {changed_element}:** {old_name}\n" - f"**New {changed_element}:** {new_name}", - colour=colour - ) - embed.set_author(name=title) - await ctx.send(embed=embed) - - @with_role(Roles.moderator, Roles.admin, Roles.owner) - @commands.command() - async def announce(self, ctx: commands.Context) -> None: - """Announces the currently loaded season.""" - await self.season.announce_season() - - def cog_unload(self) -> None: - """Cancel season-related tasks on cog unload.""" - self.season_task.cancel() diff --git a/bot/seasons/valentines/__init__.py b/bot/seasons/valentines/__init__.py deleted file mode 100644 index 6e5d16f7..00000000 --- a/bot/seasons/valentines/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -from bot.constants import Colours -from bot.seasons import SeasonBase - - -class Valentines(SeasonBase): - """ - Love is in the air! We've got a new icon and set of commands for the season of love. - - Get yourself into the bot-commands channel and check out the new features! - """ - - name = "valentines" - bot_name = "Tenderbot" - greeting = "Get loved-up!" - - start_date = "01/02" - end_date = "01/03" - - colour = Colours.pink - icon = ( - "/logos/logo_seasonal/valentines/loved_up.png", - ) diff --git a/bot/seasons/valentines/be_my_valentine.py b/bot/seasons/valentines/be_my_valentine.py deleted file mode 100644 index ab8ea290..00000000 --- a/bot/seasons/valentines/be_my_valentine.py +++ /dev/null @@ -1,234 +0,0 @@ -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 - -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 - - @commands.group(name="lovefest", invoke_without_command=True) - 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. - """ - 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)) - log.info("BeMyValentine cog loaded") diff --git a/bot/seasons/valentines/lovecalculator.py b/bot/seasons/valentines/lovecalculator.py deleted file mode 100644 index 03d3d7d5..00000000 --- a/bot/seasons/valentines/lovecalculator.py +++ /dev/null @@ -1,104 +0,0 @@ -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)) - log.info("LoveCalculator cog loaded") diff --git a/bot/seasons/valentines/movie_generator.py b/bot/seasons/valentines/movie_generator.py deleted file mode 100644 index ce1d7d5b..00000000 --- a/bot/seasons/valentines/movie_generator.py +++ /dev/null @@ -1,63 +0,0 @@ -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)) - log.info("RomanceMovieFinder cog loaded") diff --git a/bot/seasons/valentines/myvalenstate.py b/bot/seasons/valentines/myvalenstate.py deleted file mode 100644 index 0256c39a..00000000 --- a/bot/seasons/valentines/myvalenstate.py +++ /dev/null @@ -1,87 +0,0 @@ -import collections -import json -import logging -from pathlib import Path -from random import choice - -import discord -from discord.ext import commands - -from bot.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)) - log.info("MyValenstate cog loaded") diff --git a/bot/seasons/valentines/pickuplines.py b/bot/seasons/valentines/pickuplines.py deleted file mode 100644 index 8b2c9822..00000000 --- a/bot/seasons/valentines/pickuplines.py +++ /dev/null @@ -1,45 +0,0 @@ -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)) - log.info('PickupLine cog loaded') diff --git a/bot/seasons/valentines/savethedate.py b/bot/seasons/valentines/savethedate.py deleted file mode 100644 index e0bc3904..00000000 --- a/bot/seasons/valentines/savethedate.py +++ /dev/null @@ -1,42 +0,0 @@ -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)) - log.info("SaveTheDate cog loaded") diff --git a/bot/seasons/valentines/valentine_zodiac.py b/bot/seasons/valentines/valentine_zodiac.py deleted file mode 100644 index c8d77e75..00000000 --- a/bot/seasons/valentines/valentine_zodiac.py +++ /dev/null @@ -1,58 +0,0 @@ -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)) - log.info("ValentineZodiac cog loaded") diff --git a/bot/seasons/valentines/whoisvalentine.py b/bot/seasons/valentines/whoisvalentine.py deleted file mode 100644 index b8586dca..00000000 --- a/bot/seasons/valentines/whoisvalentine.py +++ /dev/null @@ -1,53 +0,0 @@ -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)) - log.info("ValentineFacts cog loaded") diff --git a/bot/seasons/wildcard/__init__.py b/bot/seasons/wildcard/__init__.py deleted file mode 100644 index 354e979d..00000000 --- a/bot/seasons/wildcard/__init__.py +++ /dev/null @@ -1,31 +0,0 @@ -from bot.seasons import SeasonBase - - -class Wildcard(SeasonBase): - """ - For the month of August, the season is a Wildcard. - - This docstring will not be used for announcements. - Instead, we'll do the announcement manually, since - it will change every year. - - This class needs slight changes every year, - such as the bot_name, bot_icon and icon. - - IMPORTANT: DO NOT ADD ANY FEATURES TO THIS FOLDER. - ALL WILDCARD FEATURES SHOULD BE ADDED - TO THE EVERGREEN FOLDER! - """ - - name = "wildcard" - bot_name = "RetroBot" - - # Duration of season - start_date = "01/08" - end_date = "01/09" - - # Season logo - bot_icon = "/logos/logo_seasonal/retro_gaming/logo_8bit_indexed_504.png" - icon = ( - "/logos/logo_seasonal/retro_gaming_animated/logo_spin_plain/logo_spin_plain_504.gif", - ) |