diff options
| author | 2021-11-29 09:15:05 +0000 | |
|---|---|---|
| committer | 2021-11-29 09:15:05 +0000 | |
| commit | c462d899f02495a4875df1f59fd97b39202ba8de (patch) | |
| tree | d6a60f9735ae21c7ef5d546ec2cfc8c84b1200c2 /bot/exts/events | |
| parent | Change `MODERATION_ROLES` and `STAFF_ROLES` constants to be a set (diff) | |
| parent | Merge pull request #951 from python-discord/file_logging (diff) | |
Merge branch 'main' into update-role-constants
Diffstat (limited to 'bot/exts/events')
| -rw-r--r-- | bot/exts/events/advent_of_code/_cog.py | 84 | ||||
| -rw-r--r-- | bot/exts/events/advent_of_code/_helpers.py | 69 | ||||
| -rw-r--r-- | bot/exts/events/advent_of_code/views/dayandstarview.py | 76 | ||||
| -rw-r--r-- | bot/exts/events/hacktoberfest/hacktober-issue-finder.py | 3 |
4 files changed, 208 insertions, 24 deletions
diff --git a/bot/exts/events/advent_of_code/_cog.py b/bot/exts/events/advent_of_code/_cog.py index 3bd4873c..c3073fd5 100644 --- a/bot/exts/events/advent_of_code/_cog.py +++ b/bot/exts/events/advent_of_code/_cog.py @@ -2,16 +2,16 @@ import json import logging from datetime import datetime, timedelta from pathlib import Path +from typing import Optional import arrow import discord from discord.ext import commands from bot.bot import Bot -from bot.constants import ( - AdventOfCode as AocConfig, Channels, Colours, Emojis, Month, Roles, STAFF_ROLES, WHITELISTED_CHANNELS, -) +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 @@ -153,7 +153,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 @@ -183,29 +183,79 @@ class AdventOfCode(commands.Cog): @in_month(Month.DECEMBER) @adventofcode_group.command( + name="dayandstar", + aliases=("daynstar", "daystar"), + brief="Get a view that lets you filter the leaderboard by day and star", + ) + @whitelist_override(channels=AOC_WHITELIST_RESTRICTED) + async def aoc_day_and_star_leaderboard( + self, + ctx: commands.Context, + maximum_scorers_day_and_star: Optional[int] = 10 + ) -> None: + """Have the bot send a View that will let you filter the leaderboard by day and star.""" + if maximum_scorers_day_and_star > AocConfig.max_day_and_star_results or maximum_scorers_day_and_star <= 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.FetchingLeaderboardFailedError: + await ctx.send(":x: Unable to fetch leaderboard!") + 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_day_and_star, + 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( name="leaderboard", aliases=("board", "lb"), brief="Get a snapshot of the PyDis private AoC leaderboard", ) @whitelist_override(channels=AOC_WHITELIST_RESTRICTED) - async def aoc_leaderboard(self, ctx: commands.Context) -> None: - """Get the current top scorers of the Python Discord Leaderboard.""" + async def aoc_leaderboard( + self, + ctx: commands.Context, + self_placement_name: Optional[str] = None, + ) -> None: + """ + Get the current top scorers of the Python Discord Leaderboard. + + Additionally you can specify a `self_placement_name` + that will append the specified profile's personal stats to the top of the leaderboard + """ async with ctx.typing(): try: - leaderboard = await _helpers.fetch_leaderboard() - except _helpers.FetchingLeaderboardFailed: + leaderboard = await _helpers.fetch_leaderboard(self_placement_name=self_placement_name) + except _helpers.FetchingLeaderboardFailedError: await ctx.send(":x: Unable to fetch leaderboard!") return - number_of_participants = leaderboard["number_of_participants"] + number_of_participants = leaderboard["number_of_participants"] - top_count = min(AocConfig.leaderboard_displayed_members, number_of_participants) - header = f"Here's our current top {top_count}! {Emojis.christmas_tree * 3}" - - table = f"```\n{leaderboard['top_leaderboard']}\n```" - info_embed = _helpers.get_summary_embed(leaderboard) + top_count = min(AocConfig.leaderboard_displayed_members, number_of_participants) + self_placement_header = "(and your personal stats compared to the top 10)" if self_placement_name else "" + header = f"Here's our current top {top_count}{self_placement_header}! {Emojis.christmas_tree * 3}" + table = "```\n" \ + f"{leaderboard['placement_leaderboard'] if self_placement_name else leaderboard['top_leaderboard']}" \ + "\n```" + info_embed = _helpers.get_summary_embed(leaderboard) - await ctx.send(content=f"{header}\n\n{table}", embed=info_embed) + await ctx.send(content=f"{header}\n\n{table}", embed=info_embed) + return @in_month(Month.DECEMBER) @adventofcode_group.command( @@ -234,7 +284,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 @@ -270,7 +320,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..35258544 100644 --- a/bot/exts/events/advent_of_code/_helpers.py +++ b/bot/exts/events/advent_of_code/_helpers.py @@ -10,6 +10,7 @@ from typing import Any, Optional import aiohttp import arrow import discord +from discord.ext import commands from bot.bot import Bot from bot.constants import AdventOfCode, Channels, Colours @@ -70,6 +71,33 @@ class FetchingLeaderboardFailedError(Exception): """Raised when one or more leaderboards could not be fetched at all.""" +def _format_leaderboard_line(rank: int, data: dict[str, Any], *, is_author: bool) -> str: + """ + Build a string representing a line of the leaderboard. + + Parameters: + rank: + Rank in the leaderboard of this entry. + + data: + Mapping with entry information. + + Keyword arguments: + is_author: + Whether to address the name displayed in the returned line + personally. + + Returns: + A formatted line for the leaderboard. + """ + return AOC_TABLE_TEMPLATE.format( + rank=rank, + name=data['name'] if not is_author else f"(You) {data['name']}", + score=str(data['score']), + stars=f"({data['star_1']}, {data['star_2']})" + ) + + def leaderboard_sorting_function(entry: tuple[str, dict]) -> tuple[int, int]: """ Provide a sorting value for our leaderboard. @@ -105,6 +133,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 +151,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,13 +185,26 @@ 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: +def _format_leaderboard(leaderboard: dict[str, dict], self_placement_name: str = None) -> str: """Format the leaderboard using the AOC_TABLE_TEMPLATE.""" leaderboard_lines = [HEADER] + self_placement_exists = False for rank, data in enumerate(leaderboard.values(), start=1): + if self_placement_name and data["name"].lower() == self_placement_name.lower(): + leaderboard_lines.insert( + 1, + AOC_TABLE_TEMPLATE.format( + rank=rank, + name=f"(You) {data['name']}", + score=str(data["score"]), + stars=f"({data['star_1']}, {data['star_2']})" + ) + ) + self_placement_exists = True + continue leaderboard_lines.append( AOC_TABLE_TEMPLATE.format( rank=rank, @@ -166,7 +213,10 @@ def _format_leaderboard(leaderboard: dict[str, dict]) -> str: stars=f"({data['star_1']}, {data['star_2']})" ) ) - + if self_placement_name and not self_placement_exists: + raise commands.BadArgument( + "Sorry, your profile does not exist in this leaderboard." + ) return "\n".join(leaderboard_lines) @@ -254,7 +304,7 @@ def _get_top_leaderboard(full_leaderboard: str) -> str: @_caches.leaderboard_cache.atomic_transaction -async def fetch_leaderboard(invalidate_cache: bool = False) -> dict: +async def fetch_leaderboard(invalidate_cache: bool = False, self_placement_name: str = None) -> dict: """ Get the current Python Discord combined leaderboard. @@ -264,7 +314,6 @@ async def fetch_leaderboard(invalidate_cache: bool = False) -> dict: miss, this function is locked to one call at a time using a decorator. """ cached_leaderboard = await _caches.leaderboard_cache.to_dict() - # Check if the cached leaderboard contains everything we expect it to. If it # does not, this probably means the cache has not been created yet or has # expired in Redis. This check also accounts for a malformed cache. @@ -283,12 +332,14 @@ async def fetch_leaderboard(invalidate_cache: bool = False) -> dict: leaderboard_fetched_at = datetime.datetime.utcnow().isoformat() cached_leaderboard = { + "placement_leaderboard": json.dumps(raw_leaderboard_data), "full_leaderboard": formatted_leaderboard, "top_leaderboard": _get_top_leaderboard(formatted_leaderboard), "full_leaderboard_url": full_leaderboard_url, "leaderboard_fetched_at": leaderboard_fetched_at, "number_of_participants": number_of_participants, "daily_stats": json.dumps(parsed_leaderboard_data["daily_stats"]), + "leaderboard_per_day_and_star": json.dumps(parsed_leaderboard_data["per_day_and_star"]) } # Store the new values in Redis @@ -300,7 +351,13 @@ async def fetch_leaderboard(invalidate_cache: bool = False) -> dict: _caches.leaderboard_cache.namespace, AdventOfCode.leaderboard_cache_expiry_seconds ) - + if self_placement_name: + formatted_placement_leaderboard = _parse_raw_leaderboard_data( + json.loads(cached_leaderboard["placement_leaderboard"]) + )["leaderboard"] + cached_leaderboard["placement_leaderboard"] = _get_top_leaderboard( + _format_leaderboard(formatted_placement_leaderboard, self_placement_name=self_placement_name) + ) return cached_leaderboard 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..a0bfa316 --- /dev/null +++ b/bot/exts/events/advent_of_code/views/dayandstarview.py @@ -0,0 +1,76 @@ +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. + + Optionally, when the requested day and star data does not exist yet it returns an error message. + """ + header = AOC_DAY_AND_STAR_TEMPLATE.format( + rank="Rank", + name="Name", completion_time="Completion time (UTC)" + ) + lines = [f"{header}\n{'-' * (len(header) + 2)}"] + if not (day_and_star_data := self.data.get(f"{self.day}-{self.star}")): + return ":x: The requested data for the specified day and star does not exist yet." + for rank, scorer in enumerate(day_and_star_data[: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 diff --git a/bot/exts/events/hacktoberfest/hacktober-issue-finder.py b/bot/exts/events/hacktoberfest/hacktober-issue-finder.py index 088e7e43..1774564b 100644 --- a/bot/exts/events/hacktoberfest/hacktober-issue-finder.py +++ b/bot/exts/events/hacktoberfest/hacktober-issue-finder.py @@ -100,7 +100,8 @@ class HacktoberIssues(commands.Cog): """Format the issue data into a embed.""" title = issue["title"] issue_url = issue["url"].replace("api.", "").replace("/repos/", "/") - body = issue["body"] + # issues can have empty bodies, which in that case GitHub doesn't include the key in the API response + body = issue.get("body", "") labels = [label["name"] for label in issue["labels"]] embed = discord.Embed(title=title) |