diff options
-rw-r--r-- | bot/constants.py | 2 | ||||
-rw-r--r-- | bot/exts/christmas/advent_of_code/_cog.py | 100 | ||||
-rw-r--r-- | bot/exts/christmas/advent_of_code/_helpers.py | 178 |
3 files changed, 182 insertions, 98 deletions
diff --git a/bot/constants.py b/bot/constants.py index a58801f7..5e97fa2d 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -81,7 +81,7 @@ class AdventOfCode: fallback_session = environ.get("AOC_FALLBACK_SESSION", "") # 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 0671203e..c3b87f96 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -1,7 +1,5 @@ -import asyncio import json import logging -import math from datetime import datetime, timedelta from pathlib import Path @@ -19,8 +17,6 @@ log = logging.getLogger(__name__) AOC_REQUEST_HEADER = {"user-agent": "PythonDiscord AoC Event Bot"} -COUNTDOWN_STEP = 60 * 5 - AOC_WHITELIST_RESTRICTED = WHITELISTED_CHANNELS + (Channels.advent_of_code_commands,) # Some commands can be run in the regular advent of code channel @@ -28,90 +24,6 @@ AOC_WHITELIST_RESTRICTED = WHITELISTED_CHANNELS + (Channels.advent_of_code_comma 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() - - 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. - """ - log.info("Started `Daily AoC Notification` task") - 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!""" @@ -127,12 +39,12 @@ class AdventOfCode(commands.Cog): self.countdown_task = None self.status_task = None - 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) + 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) - status_coro = countdown_status(self.bot) + status_coro = _helpers.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) @@ -204,7 +116,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 da139e40..b7adc895 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,7 +12,8 @@ import aiohttp import discord import pytz -from bot.constants import AdventOfCode, Colours +from bot.bot import Bot +from bot.constants import AdventOfCode, Channels, Colours from bot.exts.christmas.advent_of_code import _caches log = logging.getLogger(__name__) @@ -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. @@ -393,8 +398,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, @@ -410,6 +415,173 @@ def time_left_to_aoc_midnight() -> Tuple[datetime.datetime, datetime.timedelta]: 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) 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) + 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()) + + +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.") + + # Trying to change status too early in the bot's startup sequence will fail + # 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 + # 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_est_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) + + +async def new_puzzle_notification(bot: Bot) -> None: + """ + Announce the release of a new Advent of Code puzzle. + + 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. + 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_est_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) + + 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) + + def background_task_callback(task: asyncio.Task) -> None: """Check if the finished background task failed to make sure we log errors.""" if task.cancelled(): |