diff options
-rw-r--r-- | bot/constants.py | 1 | ||||
-rw-r--r-- | bot/exts/events/advent_of_code/_cog.py | 45 | ||||
-rw-r--r-- | bot/exts/events/advent_of_code/_helpers.py | 9 | ||||
-rw-r--r-- | bot/exts/events/advent_of_code/views/dayandstarview.py | 71 |
4 files changed, 119 insertions, 7 deletions
diff --git a/bot/constants.py b/bot/constants.py index 567daadd..0720dd20 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -88,6 +88,7 @@ class AdventOfCode: ignored_days = environ.get("AOC_IGNORED_DAYS", "").split(",") leaderboard_displayed_members = 10 leaderboard_cache_expiry_seconds = 1800 + max_day_and_star_results = 15 year = int(environ.get("AOC_YEAR", datetime.utcnow().year)) role_id = int(environ.get("AOC_ROLE_ID", 518565788744024082)) diff --git a/bot/exts/events/advent_of_code/_cog.py b/bot/exts/events/advent_of_code/_cog.py index ca60e517..7dd967ec 100644 --- a/bot/exts/events/advent_of_code/_cog.py +++ b/bot/exts/events/advent_of_code/_cog.py @@ -2,6 +2,7 @@ import json import logging from datetime import datetime, timedelta from pathlib import Path +from typing import Optional import arrow import discord @@ -12,6 +13,7 @@ from bot.constants import ( AdventOfCode as AocConfig, Channels, Colours, Emojis, Month, Roles, WHITELISTED_CHANNELS, ) from bot.exts.events.advent_of_code import _helpers +from bot.exts.events.advent_of_code.views.dayandstarview import AoCDropdownView from bot.utils.decorators import InChannelCheckFailure, in_month, whitelist_override, with_role from bot.utils.extensions import invoke_help_command @@ -150,7 +152,7 @@ class AdventOfCode(commands.Cog): else: try: join_code = await _helpers.get_public_join_code(author) - except _helpers.FetchingLeaderboardFailed: + except _helpers.FetchingLeaderboardFailedError: await ctx.send(":x: Failed to get join code! Notified maintainers.") return @@ -185,14 +187,29 @@ class AdventOfCode(commands.Cog): brief="Get a snapshot of the PyDis private AoC leaderboard", ) @whitelist_override(channels=AOC_WHITELIST_RESTRICTED) - async def aoc_leaderboard(self, ctx: commands.Context) -> None: - """Get the current top scorers of the Python Discord Leaderboard.""" + async def aoc_leaderboard( + self, + ctx: commands.Context, + day_and_star: Optional[bool] = False, + maximum_scorers: Optional[int] = 10 + ) -> None: + """ + Get the current top scorers of the Python Discord Leaderboard. + + Additionally, you can provide an argument `day_and_star` (Boolean) to have the bot send a View + that will let you filter by day and star. + """ + if maximum_scorers > AocConfig.max_day_and_star_results or maximum_scorers <= 0: + raise commands.BadArgument( + f"The maximum number of results you can query is {AocConfig.max_day_and_star_results}" + ) async with ctx.typing(): try: leaderboard = await _helpers.fetch_leaderboard() - except _helpers.FetchingLeaderboardFailed: + except _helpers.FetchingLeaderboardFailedError: await ctx.send(":x: Unable to fetch leaderboard!") return + if not day_and_star: number_of_participants = leaderboard["number_of_participants"] @@ -203,6 +220,22 @@ class AdventOfCode(commands.Cog): info_embed = _helpers.get_summary_embed(leaderboard) await ctx.send(content=f"{header}\n\n{table}", embed=info_embed) + return + + # This is a dictionary that contains solvers in respect of day, and star. + # e.g. 1-1 means the solvers of the first star of the first day and their completion time + per_day_and_star = json.loads(leaderboard['leaderboard_per_day_and_star']) + view = AoCDropdownView( + day_and_star_data=per_day_and_star, + maximum_scorers=maximum_scorers, + original_author=ctx.author + ) + message = await ctx.send( + content="Please select a day and a star to filter by!", + view=view + ) + await view.wait() + await message.edit(view=None) @in_month(Month.DECEMBER) @adventofcode_group.command( @@ -231,7 +264,7 @@ class AdventOfCode(commands.Cog): """Send an embed with daily completion statistics for the Python Discord leaderboard.""" try: leaderboard = await _helpers.fetch_leaderboard() - except _helpers.FetchingLeaderboardFailed: + except _helpers.FetchingLeaderboardFailedError: await ctx.send(":x: Can't fetch leaderboard for stats right now!") return @@ -267,7 +300,7 @@ class AdventOfCode(commands.Cog): async with ctx.typing(): try: await _helpers.fetch_leaderboard(invalidate_cache=True) - except _helpers.FetchingLeaderboardFailed: + except _helpers.FetchingLeaderboardFailedError: await ctx.send(":x: Something went wrong while trying to refresh the cache!") else: await ctx.send("\N{OK Hand Sign} Refreshed leaderboard cache!") diff --git a/bot/exts/events/advent_of_code/_helpers.py b/bot/exts/events/advent_of_code/_helpers.py index 5fedb60f..af64bc81 100644 --- a/bot/exts/events/advent_of_code/_helpers.py +++ b/bot/exts/events/advent_of_code/_helpers.py @@ -105,6 +105,7 @@ def _parse_raw_leaderboard_data(raw_leaderboard_data: dict) -> dict: # The data we get from the AoC website is structured by member, not by day/star, # which means we need to iterate over the members to transpose the data to a per # star view. We need that per star view to compute rank scores per star. + per_day_star_stats = collections.defaultdict(list) for member in raw_leaderboard_data.values(): name = member["name"] if member["name"] else f"Anonymous #{member['id']}" member_id = member["id"] @@ -122,6 +123,11 @@ def _parse_raw_leaderboard_data(raw_leaderboard_data: dict) -> dict: star_results[(day, star)].append( StarResult(member_id=member_id, completion_time=completion_time) ) + per_day_star_stats[f"{day}-{star}"].append( + {'completion_time': int(data["get_star_ts"]), 'member_name': name} + ) + for key in per_day_star_stats: + per_day_star_stats[key] = sorted(per_day_star_stats[key], key=operator.itemgetter('completion_time')) # Now that we have a transposed dataset that holds the completion time of all # participants per star, we can compute the rank-based scores each participant @@ -151,7 +157,7 @@ def _parse_raw_leaderboard_data(raw_leaderboard_data: dict) -> dict: # this data to JSON in order to cache it in Redis. daily_stats[day] = {"star_one": star_one, "star_two": star_two} - return {"daily_stats": daily_stats, "leaderboard": sorted_leaderboard} + return {"daily_stats": daily_stats, "leaderboard": sorted_leaderboard, 'per_day_and_star': per_day_star_stats} def _format_leaderboard(leaderboard: dict[str, dict]) -> str: @@ -289,6 +295,7 @@ async def fetch_leaderboard(invalidate_cache: bool = False) -> dict: "leaderboard_fetched_at": leaderboard_fetched_at, "number_of_participants": number_of_participants, "daily_stats": json.dumps(parsed_leaderboard_data["daily_stats"]), + "leaderboard_per_day_and_star": json.dumps(parsed_leaderboard_data["per_day_and_star"]) } # Store the new values in Redis diff --git a/bot/exts/events/advent_of_code/views/dayandstarview.py b/bot/exts/events/advent_of_code/views/dayandstarview.py new file mode 100644 index 00000000..243db32e --- /dev/null +++ b/bot/exts/events/advent_of_code/views/dayandstarview.py @@ -0,0 +1,71 @@ +from datetime import datetime + +import discord + +AOC_DAY_AND_STAR_TEMPLATE = "{rank: >4} | {name:25.25} | {completion_time: >10}" + + +class AoCDropdownView(discord.ui.View): + """Interactive view to filter AoC stats by Day and Star.""" + + def __init__(self, original_author: discord.Member, day_and_star_data: dict[str: dict], maximum_scorers: int): + super().__init__() + self.day = 0 + self.star = 0 + self.data = day_and_star_data + self.maximum_scorers = maximum_scorers + self.original_author = original_author + + def generate_output(self) -> str: + """Generates a formatted codeblock with AoC statistics based on the currently selected day and star.""" + header = AOC_DAY_AND_STAR_TEMPLATE.format( + rank="Rank", + name="Name", completion_time="Completion time (UTC)" + ) + lines = [f"{header}\n{'-' * (len(header) + 2)}"] + + for rank, scorer in enumerate(self.data[f"{self.day}-{self.star}"][:self.maximum_scorers]): + time_data = datetime.fromtimestamp(scorer['completion_time']).strftime("%I:%M:%S %p") + lines.append(AOC_DAY_AND_STAR_TEMPLATE.format( + datastamp="", + rank=rank + 1, + name=scorer['member_name'], + completion_time=time_data) + ) + joined_lines = "\n".join(lines) + return f"Statistics for Day: {self.day}, Star: {self.star}.\n ```\n{joined_lines}\n```" + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + """Global check to ensure that the interacting user is the user who invoked the command originally.""" + return interaction.user == self.original_author + + @discord.ui.select( + placeholder="Day", + options=[discord.SelectOption(label=str(i)) for i in range(1, 26)], + custom_id="day_select" + ) + async def day_select(self, select: discord.ui.Select, interaction: discord.Interaction) -> None: + """Dropdown to choose a Day of the AoC.""" + self.day = select.values[0] + + @discord.ui.select( + placeholder="Star", + options=[discord.SelectOption(label=str(i)) for i in range(1, 3)], + custom_id="star_select" + ) + async def star_select(self, select: discord.ui.Select, interaction: discord.Interaction) -> None: + """Dropdown to choose either the first or the second star.""" + self.star = select.values[0] + + @discord.ui.button(label="Fetch", style=discord.ButtonStyle.blurple) + async def fetch(self, button: discord.ui.Button, interaction: discord.Interaction) -> None: + """Button that fetches the statistics based on the dropdown values.""" + if self.day == 0 or self.star == 0: + await interaction.response.send_message( + "You have to select a value from both of the dropdowns!", + ephemeral=True + ) + else: + await interaction.response.edit_message(content=self.generate_output()) + self.day = 0 + self.star = 0 |