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 | 175 | 
3 files changed, 179 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..8c07cdb4 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_announcement(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..f8c0dc22 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,170 @@ 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. 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 +    # 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_announcement(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(): | 
