diff options
| author | 2021-03-12 16:52:49 +0100 | |
|---|---|---|
| committer | 2021-03-13 12:39:43 +0100 | |
| commit | 8c05cf3a2202fd06ba15636f998c222694e6085e (patch) | |
| tree | ae339ae15384be66c46eece3951c45b021e889f0 | |
| parent | Branding: implement internal utility (diff) | |
Branding: implement daemon
| -rw-r--r-- | bot/exts/backend/branding/_cog.py | 112 |
1 files changed, 109 insertions, 3 deletions
diff --git a/bot/exts/backend/branding/_cog.py b/bot/exts/backend/branding/_cog.py index ddd91b5f8..4387e68b4 100644 --- a/bot/exts/backend/branding/_cog.py +++ b/bot/exts/backend/branding/_cog.py @@ -2,13 +2,13 @@ import asyncio import logging import random import typing as t -from datetime import datetime, timedelta +from datetime import datetime, time, timedelta from enum import Enum import async_timeout import discord from async_rediscache import RedisCache -from discord.ext import commands +from discord.ext import commands, tasks from bot.bot import Bot from bot.constants import Branding as BrandingConfig, Channels, Guild @@ -38,6 +38,7 @@ class Branding(commands.Cog): """Guild branding management.""" # RedisCache[ + # "daemon_active": If True, daemon auto-starts; controlled via commands (bool) # "event_path": Path from root in the branding repo (str) # "event_description": Markdown description (str) # "event_duration": Human-readable date range or 'Fallback' (str) @@ -52,10 +53,12 @@ class Branding(commands.Cog): cache_icons = RedisCache() def __init__(self, bot: Bot) -> None: - """Instantiate repository abstraction.""" + """Instantiate repository abstraction & allow daemon to start.""" self.bot = bot self.repository = BrandingRepository(bot) + self.bot.loop.create_task(self.maybe_start_daemon()) # Start depending on cache + # region: Internal utility @mock_in_debug(return_value=None) @@ -238,3 +241,106 @@ class Branding(commands.Cog): await self.send_info_embed(Channels.change_log) # endregion + # region: Daemon + + async def maybe_start_daemon(self) -> None: + """ + Start the daemon depending on cache state. + + The daemon will only start if it's been previously explicitly enabled via a command. + """ + log.debug("Checking whether daemon is enabled") + + should_begin: t.Optional[bool] = await self.cache_information.get("daemon_active") # None if never set! + + if should_begin: + self.daemon_main.start() + + async def cog_unload(self) -> None: + """ + Cancel the daemon in case of cog unload. + + This is **not** done automatically! The daemon otherwise remains active in the background. + """ + log.debug("Cog unload: cancelling daemon") + + self.daemon_main.cancel() + + @tasks.loop(hours=24) + async def daemon_main(self) -> None: + """ + Periodically synchronise guild & caches with branding repository. + + This function executes every 24 hours at midnight. We pull the currently active event from the branding + repository and check whether it matches the currently active event. If not, we apply the new event. + + However, it is also possible that an event's assets change as it's active. To account for such cases, + we check the banner & icons hashes against the currently cached values. If there is a mismatch, the + specific asset is re-applied. + + As such, the guild should always remain synchronised with the branding repository. However, the #changelog + notification is only sent in the case of entering a new event ~ no change in an on-going event will trigger + a new notification to be sent. + """ + log.debug("Daemon awakens: checking current event") + + new_event = await self.repository.get_current_event() + + if new_event is None: + log.warning("Failed to get current event from the branding repository, daemon will do nothing!") + return + + if new_event.path != await self.cache_information.get("event_path"): + log.debug("New event detected!") + await self.enter_event(new_event) + return + + log.debug("Event has not changed, checking for change in assets") + + if new_event.banner.sha != await self.cache_information.get("banner_hash"): + log.debug("Detected same-event banner change!") + await self.apply_banner(new_event.banner) + + if compound_hash(new_event.icons) != await self.cache_information.get("icons_hash"): + log.debug("Detected same-event icon change!") + await self.initiate_icon_rotation(new_event.icons) + else: + await self.maybe_rotate_icons() + + @daemon_main.before_loop + async def daemon_before(self) -> None: + """ + Wait until the next-up UTC midnight before letting `daemon_main` begin. + + This function allows the daemon to keep a consistent schedule across restarts. + + We check for a special case in which the cog's cache is empty. This indicates that we have never entered + an event (on first start-up), or that there was a cache loss. In either case, the current event gets + applied immediately, to avoid leaving the cog in an empty state. + """ + log.debug("Calculating time for daemon to sleep before first awakening") + + current_event = await self.cache_information.get("event_path") + + if current_event is None: # Maiden case ~ first start or cache loss + log.debug("Applying event immediately as cache is empty (indicating maiden case)") + + event = await self.repository.get_current_event() + + if event is None: + log.warning("Failed to fetch event ~ cache will remain empty!") + else: + await self.enter_event(event) + + now = datetime.utcnow() + + # The actual midnight moment is offset into the future in order to prevent issues with imprecise sleep + tomorrow = now + timedelta(days=1) + midnight = datetime.combine(tomorrow, time(minute=1)) + + sleep_secs = (midnight - now).total_seconds() + + log.debug(f"Sleeping {sleep_secs} seconds before next-up midnight at {midnight}") + await asyncio.sleep(sleep_secs) + + # endregion |