From 1444c81f41703a224a527eaa45381a1cf073c549 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Mon, 30 Nov 2020 01:06:18 +0100 Subject: Rewrite Advent of Code leaderboard logic I've rewritten the Advent of Code leaderboard logic. Unfortunately, nearly all of the changes made are interrelated, meaning that they've ended up in the same commit. To add a bit of structure to the extension, I've chosen for a subpackage structure instead of a single file structure. The biggest changes: - Whether or not you get a join code for the staff leaderboard will now be determined by looking for the Helpers-role. - The Python Discord Leaderboard now includes all boards, including the staff leaderboard. This is one event. - Redis is now used to set a cache expiry period. This means that our code does not have to check for cache staleness; Redis will do that for us. - The period "fetching" task has been removed. We now fetch solely when the data is needed to prevent putting unnecessary stress on the Advent of Code website. - The option to display the Global Leaderboard within Discord has been removed. Rather, we now link to the website. This simplified the code for now, although we could add it back later. - An additional command, `.aoc refresh`, has been added to allow Admins and the Events Lead to force the cache to be invalidated. This should be done sparingly to not overburden the AoC website. - I've also made sure that the daily notification task actually pings the notification role by setting the `allowed_mentions` kwarg. --- bot/exts/christmas/advent_of_code/__init__.py | 10 + bot/exts/christmas/advent_of_code/_caches.py | 5 + bot/exts/christmas/advent_of_code/_cog.py | 353 ++++++++++++++++++++++++++ bot/exts/christmas/advent_of_code/_helpers.py | 312 +++++++++++++++++++++++ 4 files changed, 680 insertions(+) create mode 100644 bot/exts/christmas/advent_of_code/__init__.py create mode 100644 bot/exts/christmas/advent_of_code/_caches.py create mode 100644 bot/exts/christmas/advent_of_code/_cog.py create mode 100644 bot/exts/christmas/advent_of_code/_helpers.py (limited to 'bot/exts/christmas/advent_of_code') diff --git a/bot/exts/christmas/advent_of_code/__init__.py b/bot/exts/christmas/advent_of_code/__init__.py new file mode 100644 index 00000000..20ac5ab9 --- /dev/null +++ b/bot/exts/christmas/advent_of_code/__init__.py @@ -0,0 +1,10 @@ +from bot.bot import Bot + + +def setup(bot: Bot) -> None: + """Advent of Code Cog load.""" + # Import the Cog at runtime to prevent side effects like defining + # RedisCache instances too early. + from ._cog import AdventOfCode + + bot.add_cog(AdventOfCode(bot)) diff --git a/bot/exts/christmas/advent_of_code/_caches.py b/bot/exts/christmas/advent_of_code/_caches.py new file mode 100644 index 00000000..32d5394f --- /dev/null +++ b/bot/exts/christmas/advent_of_code/_caches.py @@ -0,0 +1,5 @@ +import async_rediscache + +leaderboard_counts = async_rediscache.RedisCache(namespace="AOC_leaderboard_counts") +leaderboard_cache = async_rediscache.RedisCache(namespace="AOC_leaderboard_cache") +assigned_leaderboard = async_rediscache.RedisCache(namespace="AOC_assigned_leaderboard") diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py new file mode 100644 index 00000000..0df645bd --- /dev/null +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -0,0 +1,353 @@ +import asyncio +import json +import logging +import math +from datetime import datetime, timedelta +from pathlib import Path +from typing import Tuple + +import discord +from discord.ext import commands +from pytz import timezone + +from bot.bot import Bot +from bot.constants import ( + AdventOfCode as AocConfig, Channels, Colours, Emojis, Month, Roles, WHITELISTED_CHANNELS, +) +from bot.exts.christmas.advent_of_code import _helpers +from bot.utils.decorators import in_month, override_in_channel, with_role + +log = logging.getLogger(__name__) + +AOC_REQUEST_HEADER = {"user-agent": "PythonDiscord AoC Event Bot"} + +EST = timezone("EST") +COUNTDOWN_STEP = 60 * 5 + +AOC_WHITELIST = WHITELISTED_CHANNELS + (Channels.advent_of_code, Channels.advent_of_code_staff) + + +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 = "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() + + # Prevent bot from being slightly too early in trying to announce today's puzzle + await asyncio.sleep(time_left.seconds + 1) + + 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 + + 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!", + allowed_mentions=discord.AllowedMentions( + everyone=False, + users=False, + roles=[discord.Object(AocConfig.role_id)], + ) + ) + + # 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: Bot) -> None: + self.bot = bot + + self._base_url = f"https://adventofcode.com/{AocConfig.year}" + self.global_leaderboard_url = f"https://adventofcode.com/{AocConfig.year}/leaderboard" + + self.about_aoc_filepath = Path("./bot/resources/advent_of_code/about.json") + self.cached_about_aoc = self._build_about_embed() + + self.countdown_task = None + self.status_task = None + + countdown_coro = day_countdown(self.bot) + self.countdown_task = self.bot.loop.create_task(countdown_coro) + + status_coro = countdown_status(self.bot) + self.status_task = self.bot.loop.create_task(status_coro) + + @in_month(Month.DECEMBER) + @commands.group(name="adventofcode", aliases=("aoc",)) + @override_in_channel(AOC_WHITELIST) + async def adventofcode_group(self, ctx: commands.Context) -> None: + """All of the Advent of Code commands.""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @adventofcode_group.command( + name="subscribe", + aliases=("sub", "notifications", "notify", "notifs"), + brief="Notifications for new days" + ) + @override_in_channel(AOC_WHITELIST) + async def aoc_subscribe(self, ctx: commands.Context) -> None: + """Assign the role for notifications about new days being ready.""" + role = ctx.guild.get_role(AocConfig.role_id) + unsubscribe_command = f"{ctx.prefix}{ctx.command.root_parent} unsubscribe" + + if role not in ctx.author.roles: + await ctx.author.add_roles(role) + await ctx.send("Okay! You have been __subscribed__ to notifications about new Advent of Code tasks. " + f"You can run `{unsubscribe_command}` to disable them again for you.") + else: + await ctx.send("Hey, you already are receiving notifications about new Advent of Code tasks. " + f"If you don't want them any more, run `{unsubscribe_command}` instead.") + + @adventofcode_group.command(name="unsubscribe", aliases=("unsub",), brief="Notifications for new days") + @override_in_channel(AOC_WHITELIST) + async def aoc_unsubscribe(self, ctx: commands.Context) -> None: + """Remove the role for notifications about new days being ready.""" + role = ctx.guild.get_role(AocConfig.role_id) + + if role in ctx.author.roles: + await ctx.author.remove_roles(role) + await ctx.send("Okay! You have been __unsubscribed__ from notifications about new Advent of Code tasks.") + else: + await ctx.send("Hey, you don't even get any notifications about new Advent of Code tasks currently anyway.") + + @adventofcode_group.command(name="countdown", aliases=("count", "c"), brief="Return time left until next day") + @override_in_channel(AOC_WHITELIST) + async def aoc_countdown(self, ctx: commands.Context) -> None: + """Return time left until next day.""" + if not is_in_advent(): + datetime_now = datetime.now(EST) + + # Calculate the delta to this & next year's December 1st to see which one is closest and not in the past + this_year = datetime(datetime_now.year, 12, 1, tzinfo=EST) + next_year = datetime(datetime_now.year + 1, 12, 1, tzinfo=EST) + deltas = (dec_first - datetime_now for dec_first in (this_year, next_year)) + delta = min(delta for delta in deltas if delta >= timedelta()) # timedelta() gives 0 duration delta + + # Add a finer timedelta if there's less than a day left + if delta.days == 0: + delta_str = f"approximately {delta.seconds // 3600} hours" + else: + delta_str = f"{delta.days} days" + + await ctx.send(f"The Advent of Code event is not currently running. " + f"The next event will start in {delta_str}.") + return + + tomorrow, time_left = time_left_to_aoc_midnight() + + hours, minutes = time_left.seconds // 3600, time_left.seconds // 60 % 60 + + await ctx.send(f"There are {hours} hours and {minutes} minutes left until day {tomorrow.day}.") + + @adventofcode_group.command(name="about", aliases=("ab", "info"), brief="Learn about Advent of Code") + @override_in_channel(AOC_WHITELIST) + async def about_aoc(self, ctx: commands.Context) -> None: + """Respond with an explanation of all things Advent of Code.""" + await ctx.send("", embed=self.cached_about_aoc) + + @adventofcode_group.command(name="join", aliases=("j",), brief="Learn how to join the leaderboard (via DM)") + @override_in_channel(AOC_WHITELIST) + async def join_leaderboard(self, ctx: commands.Context) -> None: + """DM the user the information for joining the PyDis AoC private leaderboard.""" + author = ctx.message.author + log.info(f"{author.name} ({author.id}) has requested a PyDis AoC leaderboard code") + + if AocConfig.staff_leaderboard_id and any(r.id == Roles.helpers for r in author.roles): + join_code = AocConfig.leaderboards[AocConfig.staff_leaderboard_id].join_code + else: + join_code = await _helpers.get_public_join_code(author) + + if not join_code: + log.error(f"Failed to get a join code for user {author} ({author.id})") + error_embed = _error_embed_helper( + title="Unable to get join code", + description="Failed to get a join code to one of our boards. Please notify staff." + ) + await ctx.send(embed=error_embed) + return + + info_str = ( + "Head over to https://adventofcode.com/leaderboard/private " + f"with code `{join_code}` to join the Python Discord 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) -> None: + """Get the current top scorers of the Python Discord Leaderboard.""" + async with ctx.typing(): + leaderboard = await _helpers.fetch_leaderboard() + number_of_participants = leaderboard["number_of_participants"] + + top_count = min(AocConfig.leaderboard_displayed_members, number_of_participants) + header = f"Here's our current top {top_count}! {Emojis.christmas_tree * 3}" + + table = f"```\n{leaderboard['top_leaderboard']}\n```" + info_embed = _helpers.get_summary_embed(leaderboard) + + await ctx.send(content=f"{header}\n\n{table}", embed=info_embed) + + @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: + """Send an embed with daily completion statistics for the Python Discord leaderboard.""" + leaderboard = await _helpers.fetch_leaderboard() + + # The daily stats are serialized as JSON as they have to be cached in Redis + daily_stats = json.loads(leaderboard["daily_stats"]) + async with ctx.typing(): + lines = ["Day ⭐ ⭐⭐ | %⭐ %⭐⭐\n================================"] + for day, stars in daily_stats.items(): + star_one = stars["star_one"] + star_two = stars["star_two"] + p_star_one = star_one / leaderboard["number_of_participants"] + p_star_two = star_two / leaderboard["number_of_participants"] + lines.append( + f"{day:>2}) {star_one:>4} {star_two:>4} | {p_star_one:>7.2%} {p_star_two:>7.2%}" + ) + table = "\n".join(lines) + info_embed = _helpers.get_summary_embed(leaderboard) + await ctx.send(f"```\n{table}\n```", embed=info_embed) + + @with_role(Roles.admin, Roles.events_lead) + @adventofcode_group.command( + name="refresh", + aliases=("fetch",), + brief="Force a refresh of the leaderboard cache.", + ) + async def refresh_leaderboard(self, ctx: commands.Context) -> None: + """ + Force a refresh of the leaderboard cache. + + Note: This should be used sparingly, as we want to prevent sending too + many requests to the Advent of Code server. + """ + async with ctx.typing(): + await _helpers.fetch_leaderboard(invalidate_cache=True) + await ctx.send("\N{OK Hand Sign} Refreshed leaderboard cache!") + + def cog_unload(self) -> None: + """Cancel season-related tasks on cog unload.""" + log.debug("Unloading the cog and canceling the background task.") + self.countdown_task.cancel() + self.status_task.cancel() + + def _build_about_embed(self) -> discord.Embed: + """Build and return the informational "About AoC" embed from the resources file.""" + with self.about_aoc_filepath.open("r", encoding="utf8") as f: + embed_fields = json.load(f) + + about_embed = discord.Embed( + title=self._base_url, + colour=Colours.soft_green, + url=self._base_url, + timestamp=datetime.utcnow() + ) + about_embed.set_author(name="Advent of Code", url=self._base_url) + for field in embed_fields: + about_embed.add_field(**field) + + about_embed.set_footer(text="Last Updated") + return about_embed + + +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()) diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py new file mode 100644 index 00000000..8b85bf5d --- /dev/null +++ b/bot/exts/christmas/advent_of_code/_helpers.py @@ -0,0 +1,312 @@ +import collections +import datetime +import json +import logging +import operator +import typing + +import aiohttp +import discord + +from bot.constants import AdventOfCode, Colours +from bot.exts.christmas.advent_of_code import _caches + +log = logging.getLogger(__name__) + +PASTE_URL = "https://paste.pythondiscord.com/documents" +RAW_PASTE_URL_TEMPLATE = "https://paste.pythondiscord.com/raw/{key}" + +# Base API URL for Advent of Code Private Leaderboards +AOC_API_URL = "https://adventofcode.com/{year}/leaderboard/private/view/{leaderboard_id}.json" +AOC_REQUEST_HEADER = {"user-agent": "PythonDiscord AoC Event Bot"} + +# Leaderboard Line Template +AOC_TABLE_TEMPLATE = "{rank: >4} | {name:25.25} | {score: >5} | {stars}" +HEADER = AOC_TABLE_TEMPLATE.format(rank="", name="Name", score="Score", stars="⭐, ⭐⭐") +HEADER = f"{HEADER}\n{'-' * (len(HEADER) + 2)}" +HEADER_LINES = len(HEADER.splitlines()) +TOP_LEADERBOARD_LINES = HEADER_LINES + AdventOfCode.leaderboard_displayed_members + +# Keys that need to be set for a cached leaderboard +REQUIRED_CACHE_KEYS = ( + "full_leaderboard", + "top_leaderboard", + "full_leaderboard_url", + "leaderboard_fetched_at", + "number_of_participants", + "daily_stats", +) + +AOC_EMBED_THUMBNAIL = ( + "https://raw.githubusercontent.com/python-discord" + "/branding/master/seasonal/christmas/server_icons/festive_256.gif" +) + +# Create namedtuple that combines a participant's name and their completion +# time for a specific star. We're going to use this later to order the results +# for each star to compute the rank score. +_StarResult = collections.namedtuple("StarResult", "name completion_time") + + +def _parse_raw_leaderboard_data(raw_leaderboard_data: dict) -> dict: + """ + Parse the leaderboard data received from the AoC website. + + The data we receive from AoC is structured by member, not by day/star. This + means that we need to "transpose" the data to a per star structure in order + to calculate the rank scores each individual should get. + + As we need our data both "per participant" as well as "per day", we return + the parsed and analyzed data in both formats. + """ + # We need to get an aggregate of completion times for each star of each day, + # instead of per participant to compute the rank scores. This dictionary will + # provide such a transposed dataset. + star_results = collections.defaultdict(list) + + # As we're already iterating over the participants, we can record the number of + # first stars and second stars they've achieved right here and now. This means + # we won't have to iterate over the participants again later. + leaderboard = {} + + # The data we get from the AoC website is structured by member, not by day/star, + # which means we need to iterate over the members to transpose the data to a per + # star view. We need that per star view to compute rank scores per star. + for member in raw_leaderboard_data.values(): + name = member["name"] if member["name"] else f"Anonymous #{member['id']}" + leaderboard[name] = {"score": 0, "star_1_count": 0, "star_2_count": 0} + + # Iterate over all days for this participant + for day, stars in member["completion_day_level"].items(): + # Iterate over the complete stars for this day for this participant + for star, data in stars.items(): + # Record completion of this star for this individual + leaderboard[name][f"star_{star}_count"] += 1 + + # Record completion datetime for this participant for this day/star + completion_time = datetime.datetime.fromtimestamp(int(data['get_star_ts'])) + star_results[(day, star)].append( + _StarResult(name=name, completion_time=completion_time) + ) + + # Now that we have a transposed dataset that holds the completion time of all + # participants per star, we can compute the rank-based scores each participant + # should get for that star. + max_score = len(leaderboard) + for star in star_results.values(): + for rank, star_result in enumerate(sorted(star, key=operator.itemgetter(1))): + leaderboard[star_result.name]["score"] += max_score - rank + + # Since dictionaries now retain insertion order, let's use that + sorted_leaderboard = dict( + sorted(leaderboard.items(), key=lambda t: t[1]["score"], reverse=True) + ) + + daily_stats = {} + for day in range(1, 26): + star_one = len(star_results.get((day, 1), [])) + star_two = len(star_results.get((day, 1), [])) + daily_stats[day] = {"star_one": star_one, "star_two": star_two} + + return {"daily_stats": daily_stats, "leaderboard": sorted_leaderboard} + + +def _format_leaderboard(leaderboard: typing.Dict[str, int]) -> str: + """Format the leaderboard using the AOC_TABLE_TEMPLATE.""" + leaderboard_lines = [HEADER] + for rank, (name, results) in enumerate(leaderboard.items(), start=1): + leaderboard_lines.append( + AOC_TABLE_TEMPLATE.format( + rank=rank, + name=name, + score=str(results["score"]), + stars=f"({results['star_1_count']}, {results['star_2_count']})" + ) + ) + + return "\n".join(leaderboard_lines) + + +async def _fetch_leaderboard_data() -> typing.Dict[str, typing.Any]: + """Fetch data for all leaderboards and return a pooled result.""" + year = AdventOfCode.year + + # We'll make our requests one at a time to not flood the AoC website with + # up to six simultaneous requests. This may take a little longer, but it + # does avoid putting unnecessary stress on the Advent of Code website. + + # Container to store the raw data of each leaderboard + participants = {} + for leaderboard in AdventOfCode.leaderboards.values(): + leaderboard_url = AOC_API_URL.format(year=year, leaderboard_id=leaderboard.id) + cookies = {"session": leaderboard.session} + + # We don't need to create a session if we're going to throw it away after each request + async with aiohttp.request( + "GET", leaderboard_url, headers=AOC_REQUEST_HEADER, cookies=cookies + ) as resp: + if resp.status == 200: + raw_data = await resp.json() + + # Get the participants and store their current count + board_participants = raw_data["members"] + await _caches.leaderboard_counts.set(leaderboard.id, len(board_participants)) + participants.update(board_participants) + else: + log.warning(f"Fetching data failed for leaderboard `{leaderboard.id}`") + resp.raise_for_status() + + log.info(f"Fetched leaderboard information for {len(participants)} participants") + return participants + + +async def _upload_leaderboard(leaderboard: str) -> str: + """Upload the full leaderboard to our paste service and return the URL.""" + async with aiohttp.request("POST", PASTE_URL, data=leaderboard) as resp: + try: + resp_json = await resp.json() + except Exception: + log.exception("Failed to upload full leaderboard to paste service") + return "" + + if "key" in resp_json: + return RAW_PASTE_URL_TEMPLATE.format(key=resp_json["key"]) + + log.error(f"Unexpected response from paste service while uploading leaderboard {resp_json}") + return "" + + +def _get_top_leaderboard(full_leaderboard: str) -> str: + """Get the leaderboard up to the maximum specified entries.""" + return "\n".join(full_leaderboard.splitlines()[:TOP_LEADERBOARD_LINES]) + + +@_caches.leaderboard_cache.atomic_transaction +async def fetch_leaderboard(invalidate_cache: bool = False) -> dict: + """ + Get the current Python Discord combined leaderboard. + + The leaderboard is cached and only fetched from the API if the current data + is older than the lifetime set in the constants. To prevent multiple calls + to this function fetching new leaderboard information in case of a cache + miss, this function is locked to one call at a time using a decorator. + """ + cached_leaderboard = await _caches.leaderboard_cache.to_dict() + + # Check if the cached leaderboard contains everything we expect it to. If it + # does not, this probably means the cache has not been created yet or has + # expired in Redis. This check also accounts for a malformed cache. + if invalidate_cache or any(key not in cached_leaderboard for key in REQUIRED_CACHE_KEYS): + log.info("No leaderboard cache available, fetching leaderboards...") + # Fetch the raw data + raw_leaderboard_data = await _fetch_leaderboard_data() + + # Parse it to extract "per star, per day" data and participant scores + parsed_leaderboard_data = _parse_raw_leaderboard_data(raw_leaderboard_data) + + leaderboard = parsed_leaderboard_data["leaderboard"] + number_of_participants = len(leaderboard) + formatted_leaderboard = _format_leaderboard(leaderboard) + full_leaderboard_url = await _upload_leaderboard(formatted_leaderboard) + leaderboard_fetched_at = datetime.datetime.utcnow().isoformat() + + cached_leaderboard = { + "full_leaderboard": formatted_leaderboard, + "top_leaderboard": _get_top_leaderboard(formatted_leaderboard), + "full_leaderboard_url": full_leaderboard_url, + "leaderboard_fetched_at": leaderboard_fetched_at, + "number_of_participants": number_of_participants, + "daily_stats": json.dumps(parsed_leaderboard_data["daily_stats"]), + } + + # Store the new values in Redis + await _caches.leaderboard_cache.update(cached_leaderboard) + + # Set an expiry on the leaderboard RedisCache + with await _caches.leaderboard_cache._get_pool_connection() as connection: + await connection.expire( + _caches.leaderboard_cache.namespace, + AdventOfCode.leaderboard_cache_expiry_seconds + ) + + return cached_leaderboard + + +def get_summary_embed(leaderboard: dict) -> discord.Embed: + """Get an embed with the current summary stats of the leaderboard.""" + leaderboard_url = leaderboard['full_leaderboard_url'] + + aoc_embed = discord.Embed( + colour=Colours.soft_green, + timestamp=datetime.datetime.fromisoformat(leaderboard["leaderboard_fetched_at"]), + ) + aoc_embed.add_field( + name="Number of Participants", + value=leaderboard["number_of_participants"], + inline=True, + ) + if leaderboard_url: + aoc_embed.add_field( + name="Full Leaderboard", + value=f"[Python Discord Leaderboard]({leaderboard_url})", + inline=True, + ) + aoc_embed.set_author(name="Advent of Code", url=leaderboard_url) + aoc_embed.set_footer(text="Last Updated") + aoc_embed.set_thumbnail(url=AOC_EMBED_THUMBNAIL) + + return aoc_embed + + +async def get_public_join_code(author: discord.Member) -> typing.Optional[str]: + """ + Get the join code for one of the non-staff leaderboards. + + If a user has previously requested a join code and their assigned board + hasn't filled up yet, we'll return the same join code to prevent them from + getting join codes for multiple boards. + """ + # Make sure to fetch new leaderboard information if the cache is older than + # 30 minutes. While this still means that there could be a discrepancy + # between the current leaderboard state and the numbers we have here, this + # should work fairly well given the buffer of slots that we have. + await fetch_leaderboard() + previously_assigned_board = await _caches.assigned_leaderboard.get(author.id) + current_board_counts = await _caches.leaderboard_counts.to_dict() + + # Remove the staff board from the current board counts as it should be ignored. + current_board_counts.pop(AdventOfCode.staff_leaderboard_id, None) + + # If this user has already received a join code, we'll give them the + # exact same one to prevent them from joining multiple boards and taking + # up multiple slots. + if previously_assigned_board: + # Check if their previously assigned board still has room for them + if current_board_counts.get(previously_assigned_board, 0) < 200: + log.info(f"{author} ({author.id}) was already assigned to a board with open slots.") + return AdventOfCode.leaderboards[previously_assigned_board].join_code + + log.info( + f"User {author} ({author.id}) previously received the join code for " + f"board `{previously_assigned_board}`, but that board's now full. " + "Assigning another board to this user." + ) + + # If we don't have the current board counts cached, let's force fetching a new cache + if not current_board_counts: + log.warning("Leaderboard counts were missing from the cache unexpectedly!") + await fetch_leaderboard(invalidate_cache=True) + current_board_counts = await _caches.leaderboard_counts.to_dict() + + # Find the board with the current lowest participant count. As we can't + best_board, _count = min(current_board_counts.items(), key=operator.itemgetter(1)) + + if current_board_counts.get(best_board, 0) >= 200: + log.warning(f"User {author} `{author.id}` requested a join code, but all boards are full!") + return + + log.info(f"Assigning user {author} ({author.id}) to board `{best_board}`") + await _caches.assigned_leaderboard.set(author.id, best_board) + + # Return the join code for this board + return AdventOfCode.leaderboards[best_board].join_code -- cgit v1.2.3 From 159c2ebdbbdb2c2ebdffe9e857c9abad5f36511f Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Mon, 30 Nov 2020 01:09:37 +0100 Subject: Remove redundant _error_embed_helper function This helper function was only being used in one spot and did not factor out any logic. I've removed the helper function to just create the embed where it's needed. --- bot/exts/christmas/advent_of_code/_cog.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) (limited to 'bot/exts/christmas/advent_of_code') diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index 0df645bd..1a6715bb 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -241,9 +241,10 @@ class AdventOfCode(commands.Cog): if not join_code: log.error(f"Failed to get a join code for user {author} ({author.id})") - error_embed = _error_embed_helper( + error_embed = discord.Embed( title="Unable to get join code", - description="Failed to get a join code to one of our boards. Please notify staff." + description="Failed to get a join code to one of our boards. Please notify staff.", + colour=discord.Colour.red(), ) await ctx.send(embed=error_embed) return @@ -346,8 +347,3 @@ class AdventOfCode(commands.Cog): about_embed.set_footer(text="Last Updated") return about_embed - - -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()) -- cgit v1.2.3 From b13667cfc261ec43daad6fd592d5b952f73997fe Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Mon, 30 Nov 2020 01:18:40 +0100 Subject: Remove now redundant advent_of_code_staff constant We're no longer going to use a two-channel setup for this event, as we don't want to split the event community into two, staff and non-staff. --- bot/constants.py | 1 - bot/exts/christmas/advent_of_code/_cog.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) (limited to 'bot/exts/christmas/advent_of_code') diff --git a/bot/constants.py b/bot/constants.py index fc9929ec..e459ed21 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -76,7 +76,6 @@ class Branding: class Channels(NamedTuple): admins = 365960823622991872 advent_of_code = int(environ.get("AOC_CHANNEL_ID", 780818162836439041)) - advent_of_code_staff = int(environ.get("AOC_STAFF_CHANNEL_ID", 778646502641500181)) announcements = int(environ.get("CHANNEL_ANNOUNCEMENTS", 354619224620138496)) big_brother_logs = 468507907357409333 bot = 267659945086812160 diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index 1a6715bb..388d0592 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -24,7 +24,7 @@ AOC_REQUEST_HEADER = {"user-agent": "PythonDiscord AoC Event Bot"} EST = timezone("EST") COUNTDOWN_STEP = 60 * 5 -AOC_WHITELIST = WHITELISTED_CHANNELS + (Channels.advent_of_code, Channels.advent_of_code_staff) +AOC_WHITELIST = WHITELISTED_CHANNELS + (Channels.advent_of_code,) def is_in_advent() -> bool: -- cgit v1.2.3 From c352c80cf97620e715ee55184b54d1609312c76b Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Mon, 30 Nov 2020 01:38:11 +0100 Subject: Fix docstrings and add a few explanatory comments --- bot/constants.py | 4 ++-- bot/exts/christmas/advent_of_code/__init__.py | 2 +- bot/exts/christmas/advent_of_code/_cog.py | 4 ++-- bot/exts/christmas/advent_of_code/_helpers.py | 3 +++ 4 files changed, 8 insertions(+), 5 deletions(-) (limited to 'bot/exts/christmas/advent_of_code') diff --git a/bot/constants.py b/bot/constants.py index 00d75a3f..cb5a91cc 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -40,8 +40,8 @@ def _parse_aoc_leaderboard_env() -> Dict[str, AdventOfCodeLeaderboard]: Parse the environment variable containing leaderboard information. A leaderboard should be specified in the format `id,session,join_code`, - without the backticks. If more than leaderboard needs to be added to the - constants, separate the individual leaderboards with `::`. + without the backticks. If more than one leaderboard needs to be added to + the constant, separate the individual leaderboards with `::`. Example ENV: `id1,session1,join_code1::id2,session2,join_code2` """ diff --git a/bot/exts/christmas/advent_of_code/__init__.py b/bot/exts/christmas/advent_of_code/__init__.py index 20ac5ab9..3c521168 100644 --- a/bot/exts/christmas/advent_of_code/__init__.py +++ b/bot/exts/christmas/advent_of_code/__init__.py @@ -2,7 +2,7 @@ from bot.bot import Bot def setup(bot: Bot) -> None: - """Advent of Code Cog load.""" + """Set up the Advent of Code extension.""" # Import the Cog at runtime to prevent side effects like defining # RedisCache instances too early. from ._cog import AdventOfCode diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index 388d0592..19baca93 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -230,7 +230,7 @@ class AdventOfCode(commands.Cog): @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.""" + """DM the user the information for joining the Python Discord leaderboard.""" author = ctx.message.author log.info(f"{author.name} ({author.id}) has requested a PyDis AoC leaderboard code") @@ -284,7 +284,7 @@ class AdventOfCode(commands.Cog): @adventofcode_group.command( name="stats", aliases=("dailystats", "ds"), - brief="Get daily statistics for the PyDis private leaderboard" + brief="Get daily statistics for the Python Discord leaderboard" ) @override_in_channel(AOC_WHITELIST) async def private_leaderboard_daily_stats(self, ctx: commands.Context) -> None: diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py index 8b85bf5d..57aad54d 100644 --- a/bot/exts/christmas/advent_of_code/_helpers.py +++ b/bot/exts/christmas/advent_of_code/_helpers.py @@ -102,10 +102,13 @@ def _parse_raw_leaderboard_data(raw_leaderboard_data: dict) -> dict: sorted(leaderboard.items(), key=lambda t: t[1]["score"], reverse=True) ) + # Create summary stats for the stars completed for each day of the event. daily_stats = {} for day in range(1, 26): star_one = len(star_results.get((day, 1), [])) star_two = len(star_results.get((day, 1), [])) + # By using a dictionary instead of namedtuple here, we can serialize + # this data to JSON in order to cache it in Redis. daily_stats[day] = {"star_one": star_one, "star_two": star_two} return {"daily_stats": daily_stats, "leaderboard": sorted_leaderboard} -- cgit v1.2.3 From c6b89c21dc7d73e4286cf67df421772c1e6df77d Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Mon, 30 Nov 2020 01:52:08 +0100 Subject: Add global leaderboard command back I accidentally removed the global leaderboard command. I've added it back! --- bot/exts/christmas/advent_of_code/_cog.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) (limited to 'bot/exts/christmas/advent_of_code') diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index 19baca93..3d1d268f 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -281,6 +281,22 @@ class AdventOfCode(commands.Cog): await ctx.send(content=f"{header}\n\n{table}", embed=info_embed) + @adventofcode_group.command( + name="global", + aliases=("globalboard", "gb"), + brief="Get a link to the global leaderboard", + ) + @override_in_channel(AOC_WHITELIST) + async def aoc_global_leaderboard(self, ctx: commands.Context) -> None: + """Get a link to the global Advent of Code leaderboard.""" + url = self.global_leaderboard_url + global_leaderboard = discord.Embed( + title="Advent of Code — Global Leaderboard", + description=f"You can find the global leaderboard [here]({url})." + ) + global_leaderboard.set_thumbnail(url=_helpers.AOC_EMBED_THUMBNAIL) + await ctx.send(embed=global_leaderboard) + @adventofcode_group.command( name="stats", aliases=("dailystats", "ds"), -- cgit v1.2.3 From c0685c73ebbffb185bddc8e0b1e6a8e09b9f289d Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Mon, 30 Nov 2020 14:01:32 +0100 Subject: Move helper functions to the ._helpers module I've moved the helper functions to the _helpers.py module and clarified the docstring of the `is_in_advent` helper function. --- bot/exts/christmas/advent_of_code/_cog.py | 44 ++++++--------------------- bot/exts/christmas/advent_of_code/_helpers.py | 33 ++++++++++++++++++++ 2 files changed, 42 insertions(+), 35 deletions(-) (limited to 'bot/exts/christmas/advent_of_code') diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index 3d1d268f..bc2a4724 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -4,11 +4,9 @@ import logging import math from datetime import datetime, timedelta from pathlib import Path -from typing import Tuple import discord from discord.ext import commands -from pytz import timezone from bot.bot import Bot from bot.constants import ( @@ -21,39 +19,15 @@ log = logging.getLogger(__name__) AOC_REQUEST_HEADER = {"user-agent": "PythonDiscord AoC Event Bot"} -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() + while _helpers.is_in_advent(): + _, time_left = _helpers.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 @@ -84,8 +58,8 @@ async def day_countdown(bot: commands.Bot) -> None: 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() + while _helpers.is_in_advent(): + tomorrow, time_left = _helpers.time_left_to_aoc_midnight() # Prevent bot from being slightly too early in trying to announce today's puzzle await asyncio.sleep(time_left.seconds + 1) @@ -196,12 +170,12 @@ class AdventOfCode(commands.Cog): @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) + if not _helpers.is_in_advent(): + datetime_now = datetime.now(_helpers.EST) # Calculate the delta to this & next year's December 1st to see which one is closest and not in the past - this_year = datetime(datetime_now.year, 12, 1, tzinfo=EST) - next_year = datetime(datetime_now.year + 1, 12, 1, tzinfo=EST) + this_year = datetime(datetime_now.year, 12, 1, tzinfo=_helpers.EST) + next_year = datetime(datetime_now.year + 1, 12, 1, tzinfo=_helpers.EST) deltas = (dec_first - datetime_now for dec_first in (this_year, next_year)) delta = min(delta for delta in deltas if delta >= timedelta()) # timedelta() gives 0 duration delta @@ -215,7 +189,7 @@ class AdventOfCode(commands.Cog): f"The next event will start in {delta_str}.") return - tomorrow, time_left = time_left_to_aoc_midnight() + tomorrow, time_left = _helpers.time_left_to_aoc_midnight() hours, minutes = time_left.seconds // 3600, time_left.seconds // 60 % 60 diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py index 57aad54d..7ac54322 100644 --- a/bot/exts/christmas/advent_of_code/_helpers.py +++ b/bot/exts/christmas/advent_of_code/_helpers.py @@ -4,9 +4,11 @@ import json import logging import operator import typing +from typing import Tuple import aiohttp import discord +import pytz from bot.constants import AdventOfCode, Colours from bot.exts.christmas.advent_of_code import _caches @@ -42,6 +44,9 @@ AOC_EMBED_THUMBNAIL = ( "/branding/master/seasonal/christmas/server_icons/festive_256.gif" ) +# Create an easy constant for the EST timezone +EST = pytz.timezone("EST") + # Create namedtuple that combines a participant's name and their completion # time for a specific star. We're going to use this later to order the results # for each star to compute the rank score. @@ -313,3 +318,31 @@ async def get_public_join_code(author: discord.Member) -> typing.Optional[str]: # Return the join code for this board return AdventOfCode.leaderboards[best_board].join_code + + +def is_in_advent() -> bool: + """ + Check if we're currently on an Advent of Code day, excluding 25 December. + + This helper function is used to check whether or not a feature that prepares + something for the next Advent of Code challenge should run. As the puzzle + published on the 25th is the last puzzle, this check excludes that date. + """ + return datetime.datetime.now(EST).day in range(1, 25) and datetime.datetime.now(EST).month == 12 + + +def time_left_to_aoc_midnight() -> Tuple[datetime.datetime, 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.datetime.now(EST).replace( + microsecond=0, + second=0, + minute=0, + hour=0 + ) + + # We want tomorrow so add a day on + tomorrow = todays_midnight + datetime.timedelta(days=1) + + # Calculate the timedelta between the current time and midnight + return tomorrow, tomorrow - datetime.datetime.now(EST) -- cgit v1.2.3 From d3a708e8921add6a70302ddc69d4e4f00edee32a Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Mon, 30 Nov 2020 16:12:30 +0100 Subject: Enable AOC commands before December Note: This won't start the countdown functions yet, they still rely on the cog being loaded in december. --- bot/exts/christmas/advent_of_code/_cog.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) (limited to 'bot/exts/christmas/advent_of_code') diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index bc2a4724..646671c7 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -127,7 +127,6 @@ class AdventOfCode(commands.Cog): status_coro = countdown_status(self.bot) self.status_task = self.bot.loop.create_task(status_coro) - @in_month(Month.DECEMBER) @commands.group(name="adventofcode", aliases=("aoc",)) @override_in_channel(AOC_WHITELIST) async def adventofcode_group(self, ctx: commands.Context) -> None: @@ -143,6 +142,11 @@ class AdventOfCode(commands.Cog): @override_in_channel(AOC_WHITELIST) async def aoc_subscribe(self, ctx: commands.Context) -> None: """Assign the role for notifications about new days being ready.""" + current_year = datetime.now().year + if current_year != AocConfig.year: + await ctx.send(f"You can't subscribe to {current_year}'s Advent of Code announcements yet!") + return + role = ctx.guild.get_role(AocConfig.role_id) unsubscribe_command = f"{ctx.prefix}{ctx.command.root_parent} unsubscribe" @@ -154,6 +158,7 @@ class AdventOfCode(commands.Cog): await ctx.send("Hey, you already are receiving notifications about new Advent of Code tasks. " f"If you don't want them any more, run `{unsubscribe_command}` instead.") + @in_month(Month.DECEMBER) @adventofcode_group.command(name="unsubscribe", aliases=("unsub",), brief="Notifications for new days") @override_in_channel(AOC_WHITELIST) async def aoc_unsubscribe(self, ctx: commands.Context) -> None: @@ -205,6 +210,11 @@ class AdventOfCode(commands.Cog): @override_in_channel(AOC_WHITELIST) async def join_leaderboard(self, ctx: commands.Context) -> None: """DM the user the information for joining the Python Discord leaderboard.""" + current_year = datetime.now().year + if current_year != AocConfig.year: + await ctx.send(f"The Python Discord leaderboard for {current_year} is not yet available!") + return + author = ctx.message.author log.info(f"{author.name} ({author.id}) has requested a PyDis AoC leaderboard code") -- cgit v1.2.3 From 9173abcfefd1e6bf9de3ae1d5f6fcd392b3e8788 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Mon, 30 Nov 2020 17:42:13 +0100 Subject: Add note about refresh time to info embed --- bot/exts/christmas/advent_of_code/_helpers.py | 2 ++ 1 file changed, 2 insertions(+) (limited to 'bot/exts/christmas/advent_of_code') diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py index 7ac54322..72c1ce20 100644 --- a/bot/exts/christmas/advent_of_code/_helpers.py +++ b/bot/exts/christmas/advent_of_code/_helpers.py @@ -243,10 +243,12 @@ async def fetch_leaderboard(invalidate_cache: bool = False) -> dict: def get_summary_embed(leaderboard: dict) -> discord.Embed: """Get an embed with the current summary stats of the leaderboard.""" leaderboard_url = leaderboard['full_leaderboard_url'] + refresh_minutes = AdventOfCode.leaderboard_cache_expiry_seconds // 60 aoc_embed = discord.Embed( colour=Colours.soft_green, timestamp=datetime.datetime.fromisoformat(leaderboard["leaderboard_fetched_at"]), + description=f"*The leaderboard is refreshed every {refresh_minutes} minutes.*" ) aoc_embed.add_field( name="Number of Participants", -- cgit v1.2.3 From d664c68ecd81b2e25fd09c6de69ecafdc13a0b95 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Mon, 30 Nov 2020 18:42:41 +0100 Subject: Clarify text of DM with Advent of Code join code --- bot/exts/christmas/advent_of_code/_cog.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) (limited to 'bot/exts/christmas/advent_of_code') diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index 646671c7..2a1a776b 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -233,12 +233,14 @@ class AdventOfCode(commands.Cog): await ctx.send(embed=error_embed) return - info_str = ( - "Head over to https://adventofcode.com/leaderboard/private " - f"with code `{join_code}` to join the Python Discord leaderboard!" - ) + info_str = [ + "To join our leaderboard, follow these steps:", + "• Log in on https://adventofcode.com", + "• Head over to https://adventofcode.com/leaderboard/private", + f"• Use this code `{join_code}` to join the Python Discord leaderboard!", + ] try: - await author.send(info_str) + await author.send("\n".join(info_str)) except discord.errors.Forbidden: log.debug(f"{author.name} ({author.id}) has disabled DMs from server members") await ctx.send(f":x: {author.mention}, please (temporarily) enable DMs to receive the join code") -- cgit v1.2.3 From 210c564e1b6823617cb4f64ff5e33031dc61d487 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Tue, 1 Dec 2020 18:30:12 +0100 Subject: Add support for ignoring scores from specific days I've added support for ignoring scores from specific days. A list of days to ignore can be provided using the environment variable `AOC_IGNORED_DAYS` as a comma-separated list. This example would ignore day 1 and day 23: AOC_IGNORED_DAYS=1,23 I've also added a helper function to sort the leaderboard not only on the achieved score, but also on the number of stars an individual has completed. --- bot/constants.py | 1 + bot/exts/christmas/advent_of_code/_helpers.py | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) (limited to 'bot/exts/christmas/advent_of_code') diff --git a/bot/constants.py b/bot/constants.py index 292a242a..e313e086 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -63,6 +63,7 @@ class AdventOfCode: staff_leaderboard_id = environ.get("AOC_STAFF_LEADERBOARD_ID", "") # Other Advent of Code constants + ignored_days = [day for day in environ.get("AOC_IGNORED_DAYS", "").split(",")] leaderboard_displayed_members = 10 leaderboard_cache_expiry_seconds = 1800 year = int(environ.get("AOC_YEAR", datetime.utcnow().year)) diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py index 72c1ce20..e84348cb 100644 --- a/bot/exts/christmas/advent_of_code/_helpers.py +++ b/bot/exts/christmas/advent_of_code/_helpers.py @@ -53,6 +53,17 @@ EST = pytz.timezone("EST") _StarResult = collections.namedtuple("StarResult", "name completion_time") +def leaderboard_sorting_function(entry: typing.Tuple[str, dict]) -> typing.Tuple[int, int]: + """ + Provide a sorting value for our leaderboard. + + The leaderboard is sorted primarily on the score someone has received and + secondary on the number of stars someone has completed. + """ + result = entry[1] + return result["score"], result["star_2_count"] + result["star_1_count"] + + def _parse_raw_leaderboard_data(raw_leaderboard_data: dict) -> dict: """ Parse the leaderboard data received from the AoC website. @@ -98,13 +109,15 @@ def _parse_raw_leaderboard_data(raw_leaderboard_data: dict) -> dict: # participants per star, we can compute the rank-based scores each participant # should get for that star. max_score = len(leaderboard) - for star in star_results.values(): - for rank, star_result in enumerate(sorted(star, key=operator.itemgetter(1))): + for(day, _star), results in star_results.items(): + if day in AdventOfCode.ignored_days: + continue + for rank, star_result in enumerate(sorted(results, key=operator.itemgetter(1))): leaderboard[star_result.name]["score"] += max_score - rank # Since dictionaries now retain insertion order, let's use that sorted_leaderboard = dict( - sorted(leaderboard.items(), key=lambda t: t[1]["score"], reverse=True) + sorted(leaderboard.items(), key=leaderboard_sorting_function, reverse=True) ) # Create summary stats for the stars completed for each day of the event. -- cgit v1.2.3 From ddb3dcf00573dd46e260ac2e39bfc09e68e68e44 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Tue, 1 Dec 2020 18:33:11 +0100 Subject: Fix daily stats by converting day, star to str The daily stats function contained a bug that prevented it from working correctly. The reason was that I was looking for `int` keys where the actual keys were strings. I now make sure to create a `str` from the `int` I get back from `range`. --- bot/exts/christmas/advent_of_code/_helpers.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) (limited to 'bot/exts/christmas/advent_of_code') diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py index e84348cb..f4a20955 100644 --- a/bot/exts/christmas/advent_of_code/_helpers.py +++ b/bot/exts/christmas/advent_of_code/_helpers.py @@ -123,8 +123,9 @@ def _parse_raw_leaderboard_data(raw_leaderboard_data: dict) -> dict: # Create summary stats for the stars completed for each day of the event. daily_stats = {} for day in range(1, 26): - star_one = len(star_results.get((day, 1), [])) - star_two = len(star_results.get((day, 1), [])) + day = str(day) + star_one = len(star_results.get((day, "1"), [])) + star_two = len(star_results.get((day, "2"), [])) # By using a dictionary instead of namedtuple here, we can serialize # this data to JSON in order to cache it in Redis. daily_stats[day] = {"star_one": star_one, "star_two": star_two} -- cgit v1.2.3 From 82a600747e4142f1c7073c2be57c520be6455a87 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Tue, 1 Dec 2020 22:09:57 +0100 Subject: Add function that allows tasks to sleep until AoC Our Advent of Code background tasks were written in such a way that they relied on the extension being loaded in December and only in December. However, since the deseasonification, the extension is loaded prior to December, with some commands being locked to that month with a check instead. This meant that our background tasks immediately cancelled themselves, as they observed themselves to be running outside of the boundaries of the event. As there was no mechanism for starting them back up again, these tasks would only start running again after redeployment of Sir Lancebot. To solve this issue, I've added a helper function that allows tasks to wait until a x hours before the event starts. This allows them to wake up in time to prepare for the release of the first puzzle. --- bot/exts/christmas/advent_of_code/_helpers.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) (limited to 'bot/exts/christmas/advent_of_code') diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py index f4a20955..145fa30a 100644 --- a/bot/exts/christmas/advent_of_code/_helpers.py +++ b/bot/exts/christmas/advent_of_code/_helpers.py @@ -1,3 +1,4 @@ +import asyncio import collections import datetime import json @@ -362,3 +363,24 @@ def time_left_to_aoc_midnight() -> Tuple[datetime.datetime, datetime.timedelta]: # Calculate the timedelta between the current time and midnight return tomorrow, tomorrow - datetime.datetime.now(EST) + + +async def wait_for_advent_of_code(*, hours_before: int = 1) -> None: + """ + Wait for the Advent of Code event to start. + + This function returns `hours_before` (default: 1) before the Advent of Code + actually starts. This allows functions to schedule and execute code that + needs to run before the event starts. + """ + start = datetime.datetime(AdventOfCode.year, 12, 1, 0, 0, 0, tzinfo=EST) + target = start - datetime.timedelta(hours=hours_before) + now = datetime.datetime.now(EST) + + # If we've already reached or passed to target, we + # simply return immediately. + if now >= target: + return + + delta = target - now + await asyncio.sleep(delta.total_seconds()) -- cgit v1.2.3 From cb418139b19aae38d384746ee0d7e149094c05b1 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Tue, 1 Dec 2020 22:22:29 +0100 Subject: Let status update task sleep until the AoC starts I've refactored the rich presence countdown task by making it hibernate until 2 hours before the next Advent of Code starts if the task starts up before the event has started. This ensures that the task will run when the event starts and allows it to countdown to the first challenge. After the event for the configured Advent of Code year has finished, the task will terminate. This also means that the task will terminate immediately in the year following the currently configured Advent of Code; it will only start hibernating again once we configure the bot for the next event. No unnecessary, year-long hibernation. --- bot/exts/christmas/advent_of_code/_cog.py | 32 +-------------- bot/exts/christmas/advent_of_code/_helpers.py | 59 +++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 31 deletions(-) (limited to 'bot/exts/christmas/advent_of_code') diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index 2a1a776b..57043454 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -1,7 +1,6 @@ import asyncio import json import logging -import math from datetime import datetime, timedelta from pathlib import Path @@ -19,38 +18,9 @@ log = logging.getLogger(__name__) AOC_REQUEST_HEADER = {"user-agent": "PythonDiscord AoC Event Bot"} -COUNTDOWN_STEP = 60 * 5 - AOC_WHITELIST = WHITELISTED_CHANNELS + (Channels.advent_of_code,) -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 _helpers.is_in_advent(): - _, time_left = _helpers.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 = "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. @@ -124,7 +94,7 @@ class AdventOfCode(commands.Cog): countdown_coro = day_countdown(self.bot) self.countdown_task = self.bot.loop.create_task(countdown_coro) - status_coro = countdown_status(self.bot) + status_coro = _helpers.countdown_status(self.bot) self.status_task = self.bot.loop.create_task(status_coro) @commands.group(name="adventofcode", aliases=("aoc",)) diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py index 145fa30a..57ad001a 100644 --- a/bot/exts/christmas/advent_of_code/_helpers.py +++ b/bot/exts/christmas/advent_of_code/_helpers.py @@ -3,6 +3,7 @@ import collections import datetime import json import logging +import math import operator import typing from typing import Tuple @@ -11,6 +12,7 @@ import aiohttp import discord import pytz +from bot.bot import Bot from bot.constants import AdventOfCode, Colours from bot.exts.christmas.advent_of_code import _caches @@ -48,6 +50,9 @@ AOC_EMBED_THUMBNAIL = ( # Create an easy constant for the EST timezone EST = pytz.timezone("EST") +# Step size for the challenge countdown status +COUNTDOWN_STEP = 60 * 5 + # Create namedtuple that combines a participant's name and their completion # time for a specific star. We're going to use this later to order the results # for each star to compute the rank score. @@ -384,3 +389,57 @@ async def wait_for_advent_of_code(*, hours_before: int = 1) -> None: delta = target - now await asyncio.sleep(delta.total_seconds()) + + +async def countdown_status(bot: Bot) -> None: + """ + Add the time until the next challenge is published to the bot's status. + + This function sleeps until 2 hours before the event and exists one hour + after the last challenge has been published. It will not start up again + automatically for next year's event, as it will wait for the environment + variable AOC_YEAR to be updated. + + This ensures that the task will only start sleeping again once the next + event approaches and we're making preparations for that event. + """ + log.debug("Initializing status countdown task.") + # We wait until 2 hours before the event starts. Then we + # set our first countdown status. + await wait_for_advent_of_code(hours_before=2) + + # Log that we're going to start with the countdown status. + log.info("The Advent of Code has started or will start soon, starting countdown status.") + + # Calculate when the task needs to stop running. To prevent the task from + # sleeping for the entire year, it will only wait in the currently + # configured year. This means that the task will only start hibernating once + # we start preparing the next event by changing environment variables. + last_challenge = datetime.datetime(AdventOfCode.year, 12, 25, 0, 0, 0, tzinfo=EST) + end = last_challenge + datetime.timedelta(hours=1) + + while datetime.datetime.now(EST) < end: + _, 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 = "right now!" + elif aligned_seconds == COUNTDOWN_STEP: + playing = f"in less than {minutes} minutes" + elif hours == 0: + playing = f"in {minutes} minutes" + elif hours == 23: + playing = f"since {60 - minutes} minutes ago" + else: + playing = f"in {hours} hours and {minutes} minutes" + + log.trace(f"Changing presence to {playing!r}") + # Status will look like "Playing in 5 hours and 30 minutes" + await bot.change_presence(activity=discord.Game(playing)) + + # Sleep until next aligned time or a full step if already aligned + delay = time_left.seconds % COUNTDOWN_STEP or COUNTDOWN_STEP + log.trace(f"The countdown status task will sleep for {delay} seconds.") + await asyncio.sleep(delay) -- cgit v1.2.3 From 575fedc0b4b22c712c541f7b49311ce2d02e9880 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Tue, 1 Dec 2020 22:28:16 +0100 Subject: Ensure status countdown waits for bot start-up Trying to change the rich presence status of the bot too early in the bot's start-up sequence will cause the task to fail. To make it wait, I've added a `bot.wait_until_guild_available` point before the main loop of the task starts. This seems to solve the issue reliably, despite the `guild_available` event not being directly related to when the bot's connection with the API is ready to accept presence updates. However, the `ready` event is know to fire too early, according to the discord.py community. --- bot/exts/christmas/advent_of_code/_helpers.py | 4 ++++ 1 file changed, 4 insertions(+) (limited to 'bot/exts/christmas/advent_of_code') diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py index 57ad001a..9ba4d9be 100644 --- a/bot/exts/christmas/advent_of_code/_helpers.py +++ b/bot/exts/christmas/advent_of_code/_helpers.py @@ -411,6 +411,10 @@ async def countdown_status(bot: Bot) -> None: # Log that we're going to start with the countdown status. log.info("The Advent of Code has started or will start soon, starting countdown status.") + # Trying to change status too early in the bot's startup sequence will fail + # the task. Waiting until this event seems to work well. + await bot.wait_until_guild_available() + # Calculate when the task needs to stop running. To prevent the task from # sleeping for the entire year, it will only wait in the currently # configured year. This means that the task will only start hibernating once -- cgit v1.2.3 From 3e5cff49f40158acb6417d948c2155daed2e4c29 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Tue, 1 Dec 2020 23:04:30 +0100 Subject: Let puzzle notification sleep until AoC starts Instead of cancelling the task when it starts up outside of the boundaries of the Advent of Code, the task will now hibernate until just before the event starts if starts up before December. This allows it to actually announce the first puzzle. After the announcement for the last day of the current event is made, it will terminate itself. It will only start hibernating again when we've updated the environment variables for next year's event, ensuring that it does not run unnecessarily. To prevent issues with the guild cache not being available, I've added our new `wait_until_guild_available` waiting function. I've also moved the calls that get teh role/channel to before the loop, as there's no need to get them each time the loop goes around. I've also changed the way we calculate the time we need to sleep, as the old way used truncated seconds, meaning that we would always wake up relatively early. Instead, I'm now using fractional seconds, which means we can reduce the safety padding to a fraction of second. More accurate announcement timing! --- bot/exts/christmas/advent_of_code/_cog.py | 58 +------------------- bot/exts/christmas/advent_of_code/_helpers.py | 77 ++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 58 deletions(-) (limited to 'bot/exts/christmas/advent_of_code') diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index 57043454..902391b5 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -1,4 +1,3 @@ -import asyncio import json import logging from datetime import datetime, timedelta @@ -21,61 +20,6 @@ AOC_REQUEST_HEADER = {"user-agent": "PythonDiscord AoC Event Bot"} AOC_WHITELIST = WHITELISTED_CHANNELS + (Channels.advent_of_code,) -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 _helpers.is_in_advent(): - tomorrow, time_left = _helpers.time_left_to_aoc_midnight() - - # Prevent bot from being slightly too early in trying to announce today's puzzle - await asyncio.sleep(time_left.seconds + 1) - - 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 - - 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!", - allowed_mentions=discord.AllowedMentions( - everyone=False, - users=False, - roles=[discord.Object(AocConfig.role_id)], - ) - ) - - # 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!""" @@ -91,7 +35,7 @@ class AdventOfCode(commands.Cog): self.countdown_task = None self.status_task = None - countdown_coro = day_countdown(self.bot) + countdown_coro = _helpers.day_countdown(self.bot) self.countdown_task = self.bot.loop.create_task(countdown_coro) status_coro = _helpers.countdown_status(self.bot) diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py index 9ba4d9be..1c4a01ed 100644 --- a/bot/exts/christmas/advent_of_code/_helpers.py +++ b/bot/exts/christmas/advent_of_code/_helpers.py @@ -13,7 +13,7 @@ import discord import pytz from bot.bot import Bot -from bot.constants import AdventOfCode, Colours +from bot.constants import AdventOfCode, Channels, Colours from bot.exts.christmas.advent_of_code import _caches log = logging.getLogger(__name__) @@ -447,3 +447,78 @@ async def countdown_status(bot: Bot) -> None: delay = time_left.seconds % COUNTDOWN_STEP or COUNTDOWN_STEP log.trace(f"The countdown status task will sleep for {delay} seconds.") await asyncio.sleep(delay) + + +async def day_countdown(bot: 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. + """ + # We wake up one hour before the event starts to prepare the announcement + # of the release of the first puzzle. + await wait_for_advent_of_code(hours_before=1) + + log.info("The Advent of Code has started or will start soon, waking up notification task.") + + # Ensure that the guild cache is loaded so we can get the Advent of Code + # channel and role. + await bot.wait_until_guild_available() + aoc_channel = bot.get_channel(Channels.advent_of_code) + aoc_role = aoc_channel.guild.get_role(AdventOfCode.role_id) + + if not aoc_channel: + log.error("Could not find the AoC channel to send notification in") + return + + if not aoc_role: + log.error("Could not find the AoC role to announce the daily puzzle") + return + + # The last event day is 25 December, so we only have to schedule + # a reminder if the current day is before 25 December. + end = datetime.datetime(AdventOfCode.year, 12, 25, tzinfo=EST) + while datetime.datetime.now(EST) < end: + log.trace("Started puzzle notification loop.") + tomorrow, time_left = time_left_to_aoc_midnight() + + # Use fractional `total_seconds` to wake up very close to our target, with + # padding of 0.1 seconds to ensure that we actually pass midnight. + sleep_seconds = time_left.total_seconds() + 0.1 + log.trace(f"The puzzle notification task will sleep for {sleep_seconds} seconds") + await asyncio.sleep(sleep_seconds) + + puzzle_url = f"https://adventofcode.com/{AdventOfCode.year}/day/{tomorrow.day}" + + # Check if the puzzle is already available to prevent our members from spamming + # the puzzle page before it's available by making a small HEAD request. + for retry in range(1, 5): + log.debug(f"Checking if the puzzle is already available (attempt {retry}/4)") + async with bot.http_session.head(puzzle_url, raise_for_status=False) as resp: + if resp.status == 200: + log.debug("Puzzle is available; let's send an announcement message.") + break + log.debug(f"The puzzle is not yet available (status={resp.status})") + await asyncio.sleep(10) + else: + log.error( + "The puzzle does does not appear to be available " + "at this time, canceling announcement" + ) + break + + await aoc_channel.send( + f"{aoc_role.mention} Good morning! Day {tomorrow.day} is ready to be attempted. " + f"View it online now at {puzzle_url}. Good luck!", + allowed_mentions=discord.AllowedMentions( + everyone=False, + users=False, + roles=[aoc_role], + ) + ) + + # Ensure that we don't send duplicate announcements by sleeping to well + # over midnight. This means we're certain to calculate the time to the + # next midnight at the top of the loop. + await asyncio.sleep(120) -- cgit v1.2.3 From d9e98c32fed67e1c01cd496341ef196bba88ca32 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Tue, 1 Dec 2020 23:12:26 +0100 Subject: Clarify time_left_until_est_midnight function The helper function calculates the time left until the next midnight in the EST timezone, not necessarily the next midnight during and Advent of Code event. To prevent confusion, I've clarified its function by changing the name of the function and its docstring. --- bot/exts/christmas/advent_of_code/_cog.py | 2 +- bot/exts/christmas/advent_of_code/_helpers.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) (limited to 'bot/exts/christmas/advent_of_code') diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index 902391b5..2f60e512 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -108,7 +108,7 @@ class AdventOfCode(commands.Cog): f"The next event will start in {delta_str}.") return - tomorrow, time_left = _helpers.time_left_to_aoc_midnight() + tomorrow, time_left = _helpers.time_left_to_est_midnight() hours, minutes = time_left.seconds // 3600, time_left.seconds // 60 % 60 diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py index 1c4a01ed..c75f47fa 100644 --- a/bot/exts/christmas/advent_of_code/_helpers.py +++ b/bot/exts/christmas/advent_of_code/_helpers.py @@ -353,8 +353,8 @@ def is_in_advent() -> bool: return datetime.datetime.now(EST).day in range(1, 25) and datetime.datetime.now(EST).month == 12 -def time_left_to_aoc_midnight() -> Tuple[datetime.datetime, datetime.timedelta]: - """Calculates the amount of time left until midnight in UTC-5 (Advent of Code maintainer timezone).""" +def time_left_to_est_midnight() -> Tuple[datetime.datetime, datetime.timedelta]: + """Calculate the amount of time left until midnight EST/UTC-5.""" # Change all time properties back to 00:00 todays_midnight = datetime.datetime.now(EST).replace( microsecond=0, @@ -423,7 +423,7 @@ async def countdown_status(bot: Bot) -> None: end = last_challenge + datetime.timedelta(hours=1) while datetime.datetime.now(EST) < end: - _, time_left = time_left_to_aoc_midnight() + _, time_left = time_left_to_est_midnight() aligned_seconds = int(math.ceil(time_left.seconds / COUNTDOWN_STEP)) * COUNTDOWN_STEP hours, minutes = aligned_seconds // 3600, aligned_seconds // 60 % 60 @@ -481,7 +481,7 @@ async def day_countdown(bot: Bot) -> None: end = datetime.datetime(AdventOfCode.year, 12, 25, tzinfo=EST) while datetime.datetime.now(EST) < end: log.trace("Started puzzle notification loop.") - tomorrow, time_left = time_left_to_aoc_midnight() + tomorrow, time_left = time_left_to_est_midnight() # Use fractional `total_seconds` to wake up very close to our target, with # padding of 0.1 seconds to ensure that we actually pass midnight. -- cgit v1.2.3 From d4c8c0f7184e5d494136cc2b7fc670e8ab7a8f93 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Wed, 2 Dec 2020 00:22:55 +0100 Subject: Update docstrings and fix grammar in comments I've updated some docstrings to include more information about the inner workings of some of the functions. In addition, I've also slightly reformulated some block comments to improve their grammar. Kaizen change: There was a redundant list comprehension in the Advent of Code section of the constants. I've removed it. --- bot/constants.py | 2 +- bot/exts/christmas/advent_of_code/_cog.py | 4 ++-- bot/exts/christmas/advent_of_code/_helpers.py | 24 +++++++++++++++++------- 3 files changed, 20 insertions(+), 10 deletions(-) (limited to 'bot/exts/christmas/advent_of_code') diff --git a/bot/constants.py b/bot/constants.py index e313e086..c696b202 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -63,7 +63,7 @@ class AdventOfCode: staff_leaderboard_id = environ.get("AOC_STAFF_LEADERBOARD_ID", "") # Other Advent of Code constants - ignored_days = [day for day in environ.get("AOC_IGNORED_DAYS", "").split(",")] + ignored_days = environ.get("AOC_IGNORED_DAYS", "").split(",") leaderboard_displayed_members = 10 leaderboard_cache_expiry_seconds = 1800 year = int(environ.get("AOC_YEAR", datetime.utcnow().year)) diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index 2f60e512..29dcc3cf 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -35,8 +35,8 @@ class AdventOfCode(commands.Cog): self.countdown_task = None self.status_task = None - countdown_coro = _helpers.day_countdown(self.bot) - self.countdown_task = self.bot.loop.create_task(countdown_coro) + announcement_coro = _helpers.new_puzzle_announcement(self.bot) + self.new_puzzle_announcement_task = self.bot.loop.create_task(announcement_coro) status_coro = _helpers.countdown_status(self.bot) self.status_task = self.bot.loop.create_task(status_coro) diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py index c75f47fa..7a6d873e 100644 --- a/bot/exts/christmas/advent_of_code/_helpers.py +++ b/bot/exts/christmas/advent_of_code/_helpers.py @@ -374,9 +374,16 @@ async def wait_for_advent_of_code(*, hours_before: int = 1) -> None: """ Wait for the Advent of Code event to start. - This function returns `hours_before` (default: 1) before the Advent of Code + This function returns `hours_before` (default: 1) the Advent of Code actually starts. This allows functions to schedule and execute code that needs to run before the event starts. + + If the event has already started, this function returns immediately. + + Note: The "next Advent of Code" is determined based on the current value + of the `AOC_YEAR` environment variable. This allows callers to exit early + if we're already past the Advent of Code edition the bot is currently + configured for. """ start = datetime.datetime(AdventOfCode.year, 12, 1, 0, 0, 0, tzinfo=EST) target = start - datetime.timedelta(hours=hours_before) @@ -449,12 +456,13 @@ async def countdown_status(bot: Bot) -> None: await asyncio.sleep(delay) -async def day_countdown(bot: Bot) -> None: +async def new_puzzle_announcement(bot: Bot) -> None: """ - Calculate the number of seconds left until the next day of Advent. + Announce the release of a new Advent of Code puzzle. - 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. + This background task hibernates until just before the Advent of Code starts + and will then start announcing puzzles as they are published. After the + event has finished, this task will terminate. """ # We wake up one hour before the event starts to prepare the announcement # of the release of the first puzzle. @@ -483,8 +491,10 @@ async def day_countdown(bot: Bot) -> None: log.trace("Started puzzle notification loop.") tomorrow, time_left = time_left_to_est_midnight() - # Use fractional `total_seconds` to wake up very close to our target, with - # padding of 0.1 seconds to ensure that we actually pass midnight. + # Use `total_seconds` to get the time left in fractional seconds This + # should wake us up very close to the target. As a safe guard, the sleep + # duration is padded with 0.1 second to make sure we wake up after + # midnight. sleep_seconds = time_left.total_seconds() + 0.1 log.trace(f"The puzzle notification task will sleep for {sleep_seconds} seconds") await asyncio.sleep(sleep_seconds) -- cgit v1.2.3 From 02c5c7e2b120c055b7db07d2ca9568d1ae25c399 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Wed, 2 Dec 2020 08:46:26 +0100 Subject: Fix leaderboard glitch caused by duplicate names We noticed that some entries on our leaderboard had an incorrect star count attached to their name. After a bit of digging, @HassanAbouelela discovered that this was caused by the use of the member's name as the key for the leaderboard dictionary: If different accounts used the same display name for the leaderboard, they'd be combined into one glitched score dict. The fix @HassanAbouelela wrote is to use the member id instead of the name as the key for the leaderboard. I've changed a few names here and there, but nothing major. This commit closes #536 --- bot/exts/christmas/advent_of_code/_helpers.py | 30 +++++++++++++++------------ 1 file changed, 17 insertions(+), 13 deletions(-) (limited to 'bot/exts/christmas/advent_of_code') diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py index f4a20955..e7eeedb2 100644 --- a/bot/exts/christmas/advent_of_code/_helpers.py +++ b/bot/exts/christmas/advent_of_code/_helpers.py @@ -50,7 +50,7 @@ EST = pytz.timezone("EST") # Create namedtuple that combines a participant's name and their completion # time for a specific star. We're going to use this later to order the results # for each star to compute the rank score. -_StarResult = collections.namedtuple("StarResult", "name completion_time") +StarResult = collections.namedtuple("StarResult", "member_id completion_time") def leaderboard_sorting_function(entry: typing.Tuple[str, dict]) -> typing.Tuple[int, int]: @@ -61,7 +61,7 @@ def leaderboard_sorting_function(entry: typing.Tuple[str, dict]) -> typing.Tuple secondary on the number of stars someone has completed. """ result = entry[1] - return result["score"], result["star_2_count"] + result["star_1_count"] + return result["score"], result["star_2"] + result["star_1"] def _parse_raw_leaderboard_data(raw_leaderboard_data: dict) -> dict: @@ -90,30 +90,34 @@ def _parse_raw_leaderboard_data(raw_leaderboard_data: dict) -> dict: # star view. We need that per star view to compute rank scores per star. for member in raw_leaderboard_data.values(): name = member["name"] if member["name"] else f"Anonymous #{member['id']}" - leaderboard[name] = {"score": 0, "star_1_count": 0, "star_2_count": 0} + member_id = member['id'] + leaderboard[member_id] = {"name": name, "score": 0, "star_1": 0, "star_2": 0} # Iterate over all days for this participant for day, stars in member["completion_day_level"].items(): # Iterate over the complete stars for this day for this participant for star, data in stars.items(): # Record completion of this star for this individual - leaderboard[name][f"star_{star}_count"] += 1 + leaderboard[member_id][f"star_{star}"] += 1 # Record completion datetime for this participant for this day/star completion_time = datetime.datetime.fromtimestamp(int(data['get_star_ts'])) star_results[(day, star)].append( - _StarResult(name=name, completion_time=completion_time) + StarResult(member_id=member_id, completion_time=completion_time) ) # Now that we have a transposed dataset that holds the completion time of all # participants per star, we can compute the rank-based scores each participant # should get for that star. max_score = len(leaderboard) - for(day, _star), results in star_results.items(): + for (day, _star), results in star_results.items(): + # If this day should not count in the ranking, skip it. if day in AdventOfCode.ignored_days: continue - for rank, star_result in enumerate(sorted(results, key=operator.itemgetter(1))): - leaderboard[star_result.name]["score"] += max_score - rank + + sorted_result = sorted(results, key=operator.attrgetter('completion_time')) + for rank, star_result in enumerate(sorted_result): + leaderboard[star_result.member_id]["score"] += max_score - rank # Since dictionaries now retain insertion order, let's use that sorted_leaderboard = dict( @@ -133,16 +137,16 @@ def _parse_raw_leaderboard_data(raw_leaderboard_data: dict) -> dict: return {"daily_stats": daily_stats, "leaderboard": sorted_leaderboard} -def _format_leaderboard(leaderboard: typing.Dict[str, int]) -> str: +def _format_leaderboard(leaderboard: typing.Dict[str, dict]) -> str: """Format the leaderboard using the AOC_TABLE_TEMPLATE.""" leaderboard_lines = [HEADER] - for rank, (name, results) in enumerate(leaderboard.items(), start=1): + for rank, data in enumerate(leaderboard.values(), start=1): leaderboard_lines.append( AOC_TABLE_TEMPLATE.format( rank=rank, - name=name, - score=str(results["score"]), - stars=f"({results['star_1_count']}, {results['star_2_count']})" + name=data["name"], + score=str(data["score"]), + stars=f"({data['star_1']}, {data['star_2']})" ) ) -- cgit v1.2.3 From c21014abc7c74a72363daeac3714b9fa976247ce Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sat, 5 Dec 2020 00:39:09 +0100 Subject: Mitigate session expiry by using fallback session Unfortunately, an expired session cookie wreaked havoc to our Advent of Code commands: All commands that relied on leaderboard data failed because we couldn't refresh our data and the cache had expired. To mitigate an expired session, I've added a fallback session feature that enables us to try again with a different session. While it will issue an error message to inform us to refresh the expired session cookie, it does mean that the functionality should continue to work in the mean time. The fallback session cookie is currently set to my session cookie, using an environment variable, `AOC_FALLBACK_SESSION`. It is important that the user connected to the session is a member of all boards and that it's a fresh session: We don't want our fallback to expire! At the same time, while a single fallback session works, the AoC website also does not like too many requests from a single user. That's why we'll still use a multi-session model under normal circumstances. To check for expired sessions, I've added a URL check: The Advent of Code website will silently redirect people with an expired session, issuing an 200: OK status as usual. The only way to really check for it is by comparing the final URL in the response object to the URL we set out to GET. I've added a custom exception to signal such an unexpected redirect. Finally, instead of having the commands just break, I've added an Exception signal that propagates back to the caller. The solution, with try-except, is a bit hacky and could benefit from an actual error handler, but I wanted to get things fixed first; polish can be added later. --- bot/exts/christmas/advent_of_code/_cog.py | 27 ++++++++--- bot/exts/christmas/advent_of_code/_helpers.py | 65 ++++++++++++++++++++++----- 2 files changed, 75 insertions(+), 17 deletions(-) (limited to 'bot/exts/christmas/advent_of_code') diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index 2a1a776b..0bcd9f42 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -221,7 +221,11 @@ class AdventOfCode(commands.Cog): if AocConfig.staff_leaderboard_id and any(r.id == Roles.helpers for r in author.roles): join_code = AocConfig.leaderboards[AocConfig.staff_leaderboard_id].join_code else: - join_code = await _helpers.get_public_join_code(author) + try: + join_code = await _helpers.get_public_join_code(author) + except _helpers.FetchingLeaderboardFailed: + await ctx.send(":x: Failed to get join code! Notified maintainers.") + return if not join_code: log.error(f"Failed to get a join code for user {author} ({author.id})") @@ -256,7 +260,12 @@ class AdventOfCode(commands.Cog): async def aoc_leaderboard(self, ctx: commands.Context) -> None: """Get the current top scorers of the Python Discord Leaderboard.""" async with ctx.typing(): - leaderboard = await _helpers.fetch_leaderboard() + try: + leaderboard = await _helpers.fetch_leaderboard() + except _helpers.FetchingLeaderboardFailed: + await ctx.send(":x: Unable to fetch leaderboard!") + return + number_of_participants = leaderboard["number_of_participants"] top_count = min(AocConfig.leaderboard_displayed_members, number_of_participants) @@ -291,7 +300,11 @@ class AdventOfCode(commands.Cog): @override_in_channel(AOC_WHITELIST) async def private_leaderboard_daily_stats(self, ctx: commands.Context) -> None: """Send an embed with daily completion statistics for the Python Discord leaderboard.""" - leaderboard = await _helpers.fetch_leaderboard() + try: + leaderboard = await _helpers.fetch_leaderboard() + except _helpers.FetchingLeaderboardFailed: + await ctx.send(":x: Can't fetch leaderboard for stats right now!") + return # The daily stats are serialized as JSON as they have to be cached in Redis daily_stats = json.loads(leaderboard["daily_stats"]) @@ -323,8 +336,12 @@ class AdventOfCode(commands.Cog): many requests to the Advent of Code server. """ async with ctx.typing(): - await _helpers.fetch_leaderboard(invalidate_cache=True) - await ctx.send("\N{OK Hand Sign} Refreshed leaderboard cache!") + try: + await _helpers.fetch_leaderboard(invalidate_cache=True) + except _helpers.FetchingLeaderboardFailed: + await ctx.send(":x: Something went wrong while trying to refresh the cache!") + else: + await ctx.send("\N{OK Hand Sign} Refreshed leaderboard cache!") def cog_unload(self) -> None: """Cancel season-related tasks on cog unload.""" diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py index e7eeedb2..d883c09f 100644 --- a/bot/exts/christmas/advent_of_code/_helpers.py +++ b/bot/exts/christmas/advent_of_code/_helpers.py @@ -53,6 +53,18 @@ EST = pytz.timezone("EST") StarResult = collections.namedtuple("StarResult", "member_id completion_time") +class UnexpectedRedirect(aiohttp.ClientError): + """Raised when an unexpected redirect was detected.""" + + +class UnexpectedResponseStatus(aiohttp.ClientError): + """Raised when an unexpected redirect was detected.""" + + +class FetchingLeaderboardFailed(Exception): + """Raised when one or more leaderboards could not be fetched at all.""" + + def leaderboard_sorting_function(entry: typing.Tuple[str, dict]) -> typing.Tuple[int, int]: """ Provide a sorting value for our leaderboard. @@ -153,6 +165,23 @@ def _format_leaderboard(leaderboard: typing.Dict[str, dict]) -> str: return "\n".join(leaderboard_lines) +async def _leaderboard_request(url: str, board: int, cookies: dict) -> typing.Optional[dict]: + """Make a leaderboard request using the specified session cookie.""" + async with aiohttp.request("GET", url, headers=AOC_REQUEST_HEADER, cookies=cookies) as resp: + # The Advent of Code website redirects silently with a 200 response if a + # session cookie has expired, is invalid, or was not provided. + if str(resp.url) != url: + log.error(f"Fetching leaderboard `{board}` failed! Check the session cookie.") + raise UnexpectedRedirect(f"redirected unexpectedly to {resp.url} for board `{board}`") + + # Every status other than `200` is unexpected, not only 400+ + if not resp.status == 200: + log.error(f"Unexpected response `{resp.status}` while fetching leaderboard `{board}`") + raise UnexpectedResponseStatus(f"status `{resp.status}`") + + return await resp.json() + + async def _fetch_leaderboard_data() -> typing.Dict[str, typing.Any]: """Fetch data for all leaderboards and return a pooled result.""" year = AdventOfCode.year @@ -165,22 +194,34 @@ async def _fetch_leaderboard_data() -> typing.Dict[str, typing.Any]: participants = {} for leaderboard in AdventOfCode.leaderboards.values(): leaderboard_url = AOC_API_URL.format(year=year, leaderboard_id=leaderboard.id) - cookies = {"session": leaderboard.session} - # We don't need to create a session if we're going to throw it away after each request - async with aiohttp.request( - "GET", leaderboard_url, headers=AOC_REQUEST_HEADER, cookies=cookies - ) as resp: - if resp.status == 200: - raw_data = await resp.json() - - # Get the participants and store their current count + # Two attempts, one with the original session cookie and one with the fallback session + for attempt in range(1, 3): + log.info(f"Attempting to fetch leaderboard `{leaderboard.id}` ({attempt}/2)") + cookies = {"session": leaderboard.session} + try: + raw_data = await _leaderboard_request(leaderboard_url, leaderboard.id, cookies) + except UnexpectedRedirect: + if cookies["session"] == AdventOfCode.fallback_session: + log.error("It seems like the fallback cookie has expired!") + raise FetchingLeaderboardFailed from None + + # If we're here, it means that the original session did not + # work. Let's fall back to the fallback session. + leaderboard.use_fallback_session = True + continue + except aiohttp.ClientError: + # Don't retry, something unexpected is wrong and it may not be the session. + raise FetchingLeaderboardFailed from None + else: + # Get the participants and store their current count. board_participants = raw_data["members"] await _caches.leaderboard_counts.set(leaderboard.id, len(board_participants)) participants.update(board_participants) - else: - log.warning(f"Fetching data failed for leaderboard `{leaderboard.id}`") - resp.raise_for_status() + break + else: + log.error(f"reached 'unreachable' state while fetching board `{leaderboard.id}`.") + raise FetchingLeaderboardFailed log.info(f"Fetched leaderboard information for {len(participants)} participants") return participants -- cgit v1.2.3 From d884c21e8ae2bd82370b11621230d7172cb098a3 Mon Sep 17 00:00:00 2001 From: janine9vn Date: Wed, 2 Dec 2020 09:28:32 -0500 Subject: Disallow .aoc commands in primary aoc channel Commands like `.aoc leaderboard` and `.aoc stats` proved to be spammy in the main advent of code channel. An aoc_commands channel has been added for aoc commands and this update prohibits aoc commands from being used in the primary aoc channel and adds the comands channel to the whitelist. This also specifically allows the less spammier commands: join, subscribe, unsubscribe, and countdown in the primary channel to foster discussion though. --- bot/constants.py | 1 + bot/exts/christmas/advent_of_code/_cog.py | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) (limited to 'bot/exts/christmas/advent_of_code') diff --git a/bot/constants.py b/bot/constants.py index 9e6db7a6..5dc42462 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -95,6 +95,7 @@ class Branding: class Channels(NamedTuple): admins = 365960823622991872 advent_of_code = int(environ.get("AOC_CHANNEL_ID", 782715290437943306)) + advent_of_code_commands = int(environ.get("AOC_COMMANDS_CHANNEL_ID", 783503267849437205)) announcements = int(environ.get("CHANNEL_ANNOUNCEMENTS", 354619224620138496)) big_brother_logs = 468507907357409333 bot = 267659945086812160 diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index 0bcd9f42..fad13f23 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -21,7 +21,7 @@ AOC_REQUEST_HEADER = {"user-agent": "PythonDiscord AoC Event Bot"} COUNTDOWN_STEP = 60 * 5 -AOC_WHITELIST = WHITELISTED_CHANNELS + (Channels.advent_of_code,) +AOC_WHITELIST = WHITELISTED_CHANNELS + (Channels.advent_of_code_commands,) async def countdown_status(bot: commands.Bot) -> None: @@ -139,7 +139,7 @@ class AdventOfCode(commands.Cog): aliases=("sub", "notifications", "notify", "notifs"), brief="Notifications for new days" ) - @override_in_channel(AOC_WHITELIST) + @override_in_channel(AOC_WHITELIST + (Channels.advent_of_code)) async def aoc_subscribe(self, ctx: commands.Context) -> None: """Assign the role for notifications about new days being ready.""" current_year = datetime.now().year @@ -160,7 +160,7 @@ class AdventOfCode(commands.Cog): @in_month(Month.DECEMBER) @adventofcode_group.command(name="unsubscribe", aliases=("unsub",), brief="Notifications for new days") - @override_in_channel(AOC_WHITELIST) + @override_in_channel(AOC_WHITELIST + Channels.advent_of_code) 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) @@ -172,7 +172,7 @@ class AdventOfCode(commands.Cog): 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) + @override_in_channel(AOC_WHITELIST + (Channels.advent_of_code)) async def aoc_countdown(self, ctx: commands.Context) -> None: """Return time left until next day.""" if not _helpers.is_in_advent(): @@ -207,7 +207,7 @@ class AdventOfCode(commands.Cog): 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) + @override_in_channel(AOC_WHITELIST + (Channels.advent_of_code)) async def join_leaderboard(self, ctx: commands.Context) -> None: """DM the user the information for joining the Python Discord leaderboard.""" current_year = datetime.now().year -- cgit v1.2.3 From d222ee24ec4d90c4eacbe50348fb1e9b6f1a0fed Mon Sep 17 00:00:00 2001 From: janine9vn Date: Wed, 2 Dec 2020 11:15:03 -0500 Subject: Add cog-level error handler for Incorrect Channel If any of the "spammier" commands (stats, leaderboard) are used within the primary advent of code channel, rather than a non-specific embed we instead reply with the channel they should be using. This also adds a "AOC_WHITELIST_PLUS" constant that makes it easier to adjust what channels the non-spammier aoc commands can be used in. --- bot/exts/christmas/advent_of_code/_cog.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) (limited to 'bot/exts/christmas/advent_of_code') diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index fad13f23..30030e70 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -13,7 +13,7 @@ from bot.constants import ( AdventOfCode as AocConfig, Channels, Colours, Emojis, Month, Roles, WHITELISTED_CHANNELS, ) from bot.exts.christmas.advent_of_code import _helpers -from bot.utils.decorators import in_month, override_in_channel, with_role +from bot.utils.decorators import InChannelCheckFailure, in_month, override_in_channel, with_role log = logging.getLogger(__name__) @@ -23,6 +23,10 @@ COUNTDOWN_STEP = 60 * 5 AOC_WHITELIST = WHITELISTED_CHANNELS + (Channels.advent_of_code_commands,) +# Some commands can be run in the regular advent of code channel +# They aren't spammy and foster discussion +AOC_WHITELIST_PLUS = AOC_WHITELIST + (Channels.advent_of_code,) + 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.""" @@ -128,7 +132,7 @@ class AdventOfCode(commands.Cog): self.status_task = self.bot.loop.create_task(status_coro) @commands.group(name="adventofcode", aliases=("aoc",)) - @override_in_channel(AOC_WHITELIST) + @override_in_channel(AOC_WHITELIST_PLUS) async def adventofcode_group(self, ctx: commands.Context) -> None: """All of the Advent of Code commands.""" if not ctx.invoked_subcommand: @@ -139,7 +143,7 @@ class AdventOfCode(commands.Cog): aliases=("sub", "notifications", "notify", "notifs"), brief="Notifications for new days" ) - @override_in_channel(AOC_WHITELIST + (Channels.advent_of_code)) + @override_in_channel(AOC_WHITELIST_PLUS) async def aoc_subscribe(self, ctx: commands.Context) -> None: """Assign the role for notifications about new days being ready.""" current_year = datetime.now().year @@ -160,7 +164,7 @@ class AdventOfCode(commands.Cog): @in_month(Month.DECEMBER) @adventofcode_group.command(name="unsubscribe", aliases=("unsub",), brief="Notifications for new days") - @override_in_channel(AOC_WHITELIST + Channels.advent_of_code) + @override_in_channel(AOC_WHITELIST_PLUS) 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) @@ -172,7 +176,7 @@ class AdventOfCode(commands.Cog): 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 + (Channels.advent_of_code)) + @override_in_channel(AOC_WHITELIST_PLUS) async def aoc_countdown(self, ctx: commands.Context) -> None: """Return time left until next day.""" if not _helpers.is_in_advent(): @@ -207,7 +211,7 @@ class AdventOfCode(commands.Cog): 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 + (Channels.advent_of_code)) + @override_in_channel(AOC_WHITELIST_PLUS) async def join_leaderboard(self, ctx: commands.Context) -> None: """DM the user the information for joining the Python Discord leaderboard.""" current_year = datetime.now().year @@ -366,3 +370,11 @@ class AdventOfCode(commands.Cog): about_embed.set_footer(text="Last Updated") return about_embed + + async def cog_command_error(self, ctx: commands.Context, error: Exception) -> None: + """Custom error handler if an advent of code command was posted in the wrong channel.""" + if isinstance(error, InChannelCheckFailure): + await ctx.send(f":x: Please use <#{Channels.advent_of_code_commands}> for aoc commands instead, please.") + ctx.command.on_error = True + else: + raise error -- cgit v1.2.3 From 15a511d5895c5c1f9ec68b968fa4e0ff55a416a3 Mon Sep 17 00:00:00 2001 From: janine9vn Date: Wed, 2 Dec 2020 11:21:27 -0500 Subject: Change custom error handler to match new style I'm a bit ahead of the game and changing the error handler to match the new style that Iceman will PR shortly. --- bot/exts/christmas/advent_of_code/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'bot/exts/christmas/advent_of_code') diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index 30030e70..0ad718b9 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -375,6 +375,6 @@ class AdventOfCode(commands.Cog): """Custom error handler if an advent of code command was posted in the wrong channel.""" if isinstance(error, InChannelCheckFailure): await ctx.send(f":x: Please use <#{Channels.advent_of_code_commands}> for aoc commands instead, please.") - ctx.command.on_error = True + error.handled = True else: raise error -- cgit v1.2.3 From b6fa6385c82e216d9ef5d2cefe1dd1efc470008f Mon Sep 17 00:00:00 2001 From: janine9vn Date: Fri, 11 Dec 2020 15:03:57 -0500 Subject: Remove re-raising the error Per Mark's comment, re-raising the error isn't necessary. --- bot/exts/christmas/advent_of_code/_cog.py | 2 -- 1 file changed, 2 deletions(-) (limited to 'bot/exts/christmas/advent_of_code') diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index 0ad718b9..b6462ab2 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -376,5 +376,3 @@ class AdventOfCode(commands.Cog): if isinstance(error, InChannelCheckFailure): await ctx.send(f":x: Please use <#{Channels.advent_of_code_commands}> for aoc commands instead, please.") error.handled = True - else: - raise error -- cgit v1.2.3 From f493699a4a076bebcd69404be57c3a32da5a22a1 Mon Sep 17 00:00:00 2001 From: janine9vn Date: Fri, 11 Dec 2020 17:11:41 -0500 Subject: Remove extra please Please -= 1 --- bot/exts/christmas/advent_of_code/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'bot/exts/christmas/advent_of_code') diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index b6462ab2..90d92fc1 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -374,5 +374,5 @@ class AdventOfCode(commands.Cog): async def cog_command_error(self, ctx: commands.Context, error: Exception) -> None: """Custom error handler if an advent of code command was posted in the wrong channel.""" if isinstance(error, InChannelCheckFailure): - await ctx.send(f":x: Please use <#{Channels.advent_of_code_commands}> for aoc commands instead, please.") + await ctx.send(f":x: Please use <#{Channels.advent_of_code_commands}> for aoc commands instead.") error.handled = True -- cgit v1.2.3 From 8f119f66ec52b1ad55f2078289d35632d0c8127a Mon Sep 17 00:00:00 2001 From: janine9vn Date: Fri, 11 Dec 2020 19:43:43 -0500 Subject: Change AOC_WHITELIST names for clarity AOC_WHITELIST was changed to AOC_WHITELIST_RESTRICTED because it is clearer that commands with this parameter in the `@override_in_channel()` decorator will be restricted to the aoc commands channel and not be allowed in the main aoc channel. In the same vein, AOC_WHITELIST_PLUS was changed to AOC_WHITELIST. --- bot/exts/christmas/advent_of_code/_cog.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) (limited to 'bot/exts/christmas/advent_of_code') diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index 90d92fc1..0968dd26 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -21,11 +21,11 @@ AOC_REQUEST_HEADER = {"user-agent": "PythonDiscord AoC Event Bot"} COUNTDOWN_STEP = 60 * 5 -AOC_WHITELIST = WHITELISTED_CHANNELS + (Channels.advent_of_code_commands,) +AOC_WHITELIST_RESTRICTED = WHITELISTED_CHANNELS + (Channels.advent_of_code_commands,) # Some commands can be run in the regular advent of code channel # They aren't spammy and foster discussion -AOC_WHITELIST_PLUS = AOC_WHITELIST + (Channels.advent_of_code,) +AOC_WHITELIST = AOC_WHITELIST_RESTRICTED + (Channels.advent_of_code,) async def countdown_status(bot: commands.Bot) -> None: @@ -132,7 +132,7 @@ class AdventOfCode(commands.Cog): self.status_task = self.bot.loop.create_task(status_coro) @commands.group(name="adventofcode", aliases=("aoc",)) - @override_in_channel(AOC_WHITELIST_PLUS) + @override_in_channel(AOC_WHITELIST) async def adventofcode_group(self, ctx: commands.Context) -> None: """All of the Advent of Code commands.""" if not ctx.invoked_subcommand: @@ -143,7 +143,7 @@ class AdventOfCode(commands.Cog): aliases=("sub", "notifications", "notify", "notifs"), brief="Notifications for new days" ) - @override_in_channel(AOC_WHITELIST_PLUS) + @override_in_channel(AOC_WHITELIST) async def aoc_subscribe(self, ctx: commands.Context) -> None: """Assign the role for notifications about new days being ready.""" current_year = datetime.now().year @@ -164,7 +164,7 @@ class AdventOfCode(commands.Cog): @in_month(Month.DECEMBER) @adventofcode_group.command(name="unsubscribe", aliases=("unsub",), brief="Notifications for new days") - @override_in_channel(AOC_WHITELIST_PLUS) + @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) @@ -176,7 +176,7 @@ class AdventOfCode(commands.Cog): 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_PLUS) + @override_in_channel(AOC_WHITELIST) async def aoc_countdown(self, ctx: commands.Context) -> None: """Return time left until next day.""" if not _helpers.is_in_advent(): @@ -211,7 +211,7 @@ class AdventOfCode(commands.Cog): 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_PLUS) + @override_in_channel(AOC_WHITELIST) async def join_leaderboard(self, ctx: commands.Context) -> None: """DM the user the information for joining the Python Discord leaderboard.""" current_year = datetime.now().year @@ -260,7 +260,7 @@ class AdventOfCode(commands.Cog): aliases=("board", "lb"), brief="Get a snapshot of the PyDis private AoC leaderboard", ) - @override_in_channel(AOC_WHITELIST) + @override_in_channel(AOC_WHITELIST_RESTRICTED) async def aoc_leaderboard(self, ctx: commands.Context) -> None: """Get the current top scorers of the Python Discord Leaderboard.""" async with ctx.typing(): @@ -285,7 +285,7 @@ class AdventOfCode(commands.Cog): aliases=("globalboard", "gb"), brief="Get a link to the global leaderboard", ) - @override_in_channel(AOC_WHITELIST) + @override_in_channel(AOC_WHITELIST_RESTRICTED) async def aoc_global_leaderboard(self, ctx: commands.Context) -> None: """Get a link to the global Advent of Code leaderboard.""" url = self.global_leaderboard_url @@ -301,7 +301,7 @@ class AdventOfCode(commands.Cog): aliases=("dailystats", "ds"), brief="Get daily statistics for the Python Discord leaderboard" ) - @override_in_channel(AOC_WHITELIST) + @override_in_channel(AOC_WHITELIST_RESTRICTED) async def private_leaderboard_daily_stats(self, ctx: commands.Context) -> None: """Send an embed with daily completion statistics for the Python Discord leaderboard.""" try: -- cgit v1.2.3 From 0a91d49969600fa2d5f5b9e429ff693f3b94da72 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sun, 13 Dec 2020 10:40:56 +0100 Subject: Add callback to log errors in AoC background tasks Currently, our Advent of Code background tasks fail without logging errors or printing error messages. This makes it difficult to debug the errors and means that they may fail silently. While we should ideally find the root cause that hides such errors, I've added a done_callback function in the meantime to help us debug the current issues with the Advent of Code Notification Task. --- bot/exts/christmas/advent_of_code/_cog.py | 6 ++++++ bot/exts/christmas/advent_of_code/_helpers.py | 11 +++++++++++ 2 files changed, 17 insertions(+) (limited to 'bot/exts/christmas/advent_of_code') diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index 0968dd26..0671203e 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -30,6 +30,7 @@ AOC_WHITELIST = AOC_WHITELIST_RESTRICTED + (Channels.advent_of_code,) 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.""" + log.info("Started `AoC Status Countdown` task") while _helpers.is_in_advent(): _, time_left = _helpers.time_left_to_aoc_midnight() @@ -62,6 +63,7 @@ async def day_countdown(bot: commands.Bot) -> None: 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. """ + log.info("Started `Daily AoC Notification` task") while _helpers.is_in_advent(): tomorrow, time_left = _helpers.time_left_to_aoc_midnight() @@ -127,9 +129,13 @@ class AdventOfCode(commands.Cog): countdown_coro = day_countdown(self.bot) self.countdown_task = self.bot.loop.create_task(countdown_coro) + self.countdown_task.set_name("Daily AoC Notification") + self.countdown_task.add_done_callback(_helpers.background_task_callback) status_coro = countdown_status(self.bot) self.status_task = self.bot.loop.create_task(status_coro) + self.status_task.set_name("AoC Status Countdown") + self.status_task.add_done_callback(_helpers.background_task_callback) @commands.group(name="adventofcode", aliases=("aoc",)) @override_in_channel(AOC_WHITELIST) diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py index d883c09f..da139e40 100644 --- a/bot/exts/christmas/advent_of_code/_helpers.py +++ b/bot/exts/christmas/advent_of_code/_helpers.py @@ -1,3 +1,4 @@ +import asyncio import collections import datetime import json @@ -407,3 +408,13 @@ def time_left_to_aoc_midnight() -> Tuple[datetime.datetime, datetime.timedelta]: # Calculate the timedelta between the current time and midnight return tomorrow, tomorrow - datetime.datetime.now(EST) + + +def background_task_callback(task: asyncio.Task) -> None: + """Check if the finished background task failed to make sure we log errors.""" + if task.cancelled(): + log.info(f"Background task `{task.get_name()}` was cancelled.") + elif exception := task.exception(): + log.error(f"Background task `{task.get_name()}` failed:", exc_info=exception) + else: + log.info(f"Background task `{task.get_name()}` exited normally.") -- cgit v1.2.3 From fb7838c6165b6b32f6561a871428330f9d8f0c7c Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sun, 13 Dec 2020 11:20:13 +0100 Subject: Clarify comment on AoC Status Task startup delay The Advent of Code Status Countdown task needs to wait for two things to happen to prevent it from failing during the startup sequence: 1. The Websocket instance discord.py creates needs to be available as an attribute of the bot, otherwise discord.py fails internally: Traceback (most recent call last): File "discord/client.py", line 1049, in change_presence await self.ws.change_presence( activity=activity, status=status, afk=afk ) File "advent_of_code/_cog.py", line 52, in countdown_status await bot.change_presence(activity=discord.Game(playing)) AttributeError: 'NoneType' object has no attribute 'change_presence' 2. Allegedly, according to the discord.py community, trying to change the status too early in the sequence to establish a connection with Discord may result ub the Discord API aborting the connection. To solve this, I've added a `wait_until_guild_available` waiter, as it guarantees that the websocket is available and the connections is mature. Kaizen: I've changed the name `new_puzzle_announcement` to `new_puzzle_notification` to better reflect its function. --- bot/exts/christmas/advent_of_code/_cog.py | 2 +- bot/exts/christmas/advent_of_code/_helpers.py | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) (limited to 'bot/exts/christmas/advent_of_code') diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index 8c07cdb4..c3b87f96 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -39,7 +39,7 @@ class AdventOfCode(commands.Cog): self.countdown_task = None self.status_task = None - notification_coro = _helpers.new_puzzle_announcement(self.bot) + notification_coro = _helpers.new_puzzle_notification(self.bot) self.notification_task = self.bot.loop.create_task(notification_coro) self.notification_task.set_name("Daily AoC Notification") self.notification_task.add_done_callback(_helpers.background_task_callback) diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py index f8c0dc22..b7adc895 100644 --- a/bot/exts/christmas/advent_of_code/_helpers.py +++ b/bot/exts/christmas/advent_of_code/_helpers.py @@ -464,7 +464,10 @@ async def countdown_status(bot: Bot) -> None: log.info("The Advent of Code has started or will start soon, starting countdown status.") # Trying to change status too early in the bot's startup sequence will fail - # the task. Waiting until this event seems to work well. + # the task because the websocket instance has not yet been created. Waiting + # for this event means that both the websocket instance has been initialized + # and that the connection to Discord is mature enough to change the presence + # of the bot. await bot.wait_until_guild_available() # Calculate when the task needs to stop running. To prevent the task from @@ -501,7 +504,7 @@ async def countdown_status(bot: Bot) -> None: await asyncio.sleep(delay) -async def new_puzzle_announcement(bot: Bot) -> None: +async def new_puzzle_notification(bot: Bot) -> None: """ Announce the release of a new Advent of Code puzzle. -- cgit v1.2.3