aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar kwzrd <[email protected]>2021-03-11 22:09:25 +0100
committerGravatar kwzrd <[email protected]>2021-03-13 12:39:42 +0100
commitac4399a4b19dfa5ae0e9856c8df546d00a7d473e (patch)
tree23f1c6f6ed649eeaf2f0df9a22460c8e5b09e330
parentBranding: expose SHA on remote objects (diff)
Branding: implement internal utility
This adds the core logic of branding management. In comparison with the previous version, we now maintain all state in Redis, which allows the bot to seamlessly restart without losing any information. The 'send_info_embed' function is intentionally implemented with the consideration of allowing users to invoke it on-demand. It always reads information from the cache, even if the caller could pass a 'MetaFile' instance. So while this may look needlessly indirect right now, it should begin to make sense once the command API is implemented.
-rw-r--r--bot/exts/backend/branding/_cog.py176
1 files changed, 174 insertions, 2 deletions
diff --git a/bot/exts/backend/branding/_cog.py b/bot/exts/backend/branding/_cog.py
index 79106d694..ddd91b5f8 100644
--- a/bot/exts/backend/branding/_cog.py
+++ b/bot/exts/backend/branding/_cog.py
@@ -1,15 +1,19 @@
import asyncio
import logging
+import random
+import typing as t
+from datetime import datetime, timedelta
from enum import Enum
import async_timeout
import discord
+from async_rediscache import RedisCache
from discord.ext import commands
from bot.bot import Bot
-from bot.constants import Guild
+from bot.constants import Branding as BrandingConfig, Channels, Guild
from bot.decorators import mock_in_debug
-from bot.exts.backend.branding._repository import BrandingRepository
+from bot.exts.backend.branding._repository import BrandingRepository, Event, RemoteObject
log = logging.getLogger(__name__)
@@ -25,9 +29,28 @@ class AssetType(Enum):
ICON = "icon"
+def compound_hash(objects: t.Iterable[RemoteObject]) -> str:
+ """Compound hashes are cached to check for change in any of the member `objects`."""
+ return "-".join(item.sha for item in objects)
+
+
class Branding(commands.Cog):
"""Guild branding management."""
+ # RedisCache[
+ # "event_path": Path from root in the branding repo (str)
+ # "event_description": Markdown description (str)
+ # "event_duration": Human-readable date range or 'Fallback' (str)
+ # "banner_hash": Hash of the last applied banner (str)
+ # "icons_hash": Compound hash of icons in rotation (str)
+ # "last_rotation_timestamp": POSIX timestamp (float)
+ # ]
+ cache_information = RedisCache()
+
+ # Cache holding icons in current rotation ~ the keys are download URLs (str) and the values are integers
+ # corresponding to the amount of times each icon has been used in the current rotation
+ cache_icons = RedisCache()
+
def __init__(self, bot: Bot) -> None:
"""Instantiate repository abstraction."""
self.bot = bot
@@ -65,4 +88,153 @@ class Branding(commands.Cog):
else:
log.debug("Asset uploaded successfully!")
+ async def apply_banner(self, banner: RemoteObject) -> None:
+ """
+ Apply `banner` to the guild and cache its hash.
+
+ Banners should always be applied via this method in order to ensure that the last hash is cached.
+ """
+ await self.apply_asset(AssetType.BANNER, banner.download_url)
+ await self.cache_information.set("banner_hash", banner.sha)
+
+ async def rotate_icons(self) -> None:
+ """
+ Choose and apply the next-up icon in rotation.
+
+ We keep track of the amount of times each icon has been used. The values in `cache_icons` can be understood
+ to be iteration IDs. When an icon is chosen & applied, we bump its count, pushing it into the next iteration.
+
+ Once the current iteration (lowest count in the cache) depletes, we move onto the next iteration.
+
+ In the case that there is only 1 icon in the rotation and has already been applied, do nothing.
+ """
+ log.debug("Rotating icons")
+
+ state = await self.cache_icons.to_dict()
+ log.trace(f"Total icons in rotation: {len(state)}")
+
+ if len(state) == 1 and 1 in state.values():
+ log.debug("Aborting icon rotation: only 1 icon is available and has already been applied")
+ return
+
+ current_iteration = min(state.values()) # Choose iteration to draw from
+ options = [download_url for download_url, times_used in state.items() if times_used == current_iteration]
+
+ log.trace(f"Choosing from {len(options)} icons in iteration {current_iteration}")
+ next_icon = random.choice(options)
+
+ await self.apply_asset(AssetType.ICON, next_icon)
+ await self.cache_icons.increment(next_icon) # Push the icon into the next iteration
+
+ timestamp = datetime.utcnow().timestamp()
+ await self.cache_information.set("last_rotation_timestamp", timestamp)
+
+ async def maybe_rotate_icons(self) -> None:
+ """
+ Call `rotate_icons` if the configured amount of time has passed since last rotation.
+
+ We offset the calculated time difference into the future in order to avoid off-by-a-little-bit errors.
+ Because there is work to be done before the timestamp is read and written, the next read will likely
+ commence slightly under 24 hours after the last write.
+ """
+ log.debug("Checking if icons should rotate")
+
+ last_rotation_timestamp = await self.cache_information.get("last_rotation_timestamp")
+
+ if last_rotation_timestamp is None: # Maiden case ~ never rotated
+ await self.rotate_icons()
+
+ last_rotation = datetime.fromtimestamp(last_rotation_timestamp)
+ difference = (datetime.utcnow() - last_rotation) + timedelta(minutes=5)
+
+ log.trace(f"Icons last rotated at {last_rotation} (difference: {difference})")
+
+ if difference.days >= BrandingConfig.cycle_frequency:
+ await self.rotate_icons()
+
+ async def initiate_icon_rotation(self, available_icons: t.List[RemoteObject]) -> None:
+ """
+ Set up a new icon rotation.
+
+ This function should be called whenever the set of `available_icons` changes. This is generally the case
+ when we enter a new event, but potentially also when the assets of an on-going event change. In such cases,
+ a reset of `cache_icons` is necessary, because it contains download URLs which may have gotten stale.
+ """
+ log.debug("Initiating new icon rotation")
+
+ await self.cache_icons.clear()
+
+ new_state = {icon.download_url: 0 for icon in available_icons}
+ await self.cache_icons.update(new_state)
+
+ log.trace(f"Icon rotation initiated for {len(new_state)} icons")
+
+ await self.rotate_icons()
+ await self.cache_information.set("icons_hash", compound_hash(available_icons))
+
+ async def send_info_embed(self, channel_id: int) -> None:
+ """
+ Send the currently cached event description to `channel_id`.
+
+ This function is called when entering a new event with the destination being #changelog. However, it can
+ also be invoked on-demand by users.
+
+ To support either case, we read information about the current event from `cache_information`. The caller
+ is therefore responsible for making sure that the cache is up-to-date before calling this function.
+ """
+ log.debug(f"Sending event information event to channel id: {channel_id}")
+
+ await self.bot.wait_until_guild_available()
+ channel: t.Optional[discord.TextChannel] = self.bot.get_channel(channel_id)
+
+ if channel is None:
+ log.warning(f"Cannot send event information: channel {channel_id} not found!")
+ return
+
+ log.debug(f"Destination channel: #{channel.name}")
+
+ embed = discord.Embed(
+ description=await self.cache_information.get("event_description"),
+ colour=discord.Colour.blurple(),
+ )
+ embed.set_footer(text=await self.cache_information.get("event_duration"))
+
+ await channel.send(embed=embed)
+
+ async def enter_event(self, event: Event) -> None:
+ """
+ Enter `event` and update information cache.
+
+ From the outside, entering a new event is as simple as applying its branding to the guild and dispatching
+ a notification to #changelog.
+
+ However, internally we cache information to ensure that we:
+ * Remember which event we're currently in across restarts
+ * Provide an on-demand information embed without re-querying the branding repository
+
+ An event change should always be handled via this function, as it ensures that the cache is populated.
+ """
+ log.debug(f"Entering new event: {event.path}")
+
+ await self.apply_banner(event.banner) # Only one asset ~ apply directly
+ await self.initiate_icon_rotation(event.icons) # Extra layer of abstraction to handle multiple assets
+
+ # Cache event identity to avoid re-entry in case of restart
+ await self.cache_information.set("event_path", event.path)
+
+ # The following values are only stored for the purpose of presenting them to the users
+ if event.meta.is_fallback:
+ event_duration = "Fallback"
+ else:
+ fmt = "%B %d" # Ex: August 23
+ start_date = event.meta.start_date.strftime(fmt)
+ end_date = event.meta.end_date.strftime(fmt)
+ event_duration = f"{start_date} - {end_date}"
+
+ await self.cache_information.set("event_duration", event_duration)
+ await self.cache_information.set("event_description", event.meta.description)
+
+ # Notify guild of new event ~ this reads the information that we cached above!
+ await self.send_info_embed(Channels.change_log)
+
# endregion