aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--bot/__main__.py8
-rw-r--r--bot/bot.py118
-rw-r--r--bot/branding.py315
-rw-r--r--bot/constants.py22
-rw-r--r--bot/decorators.py97
-rw-r--r--bot/seasons/__init__.py99
-rw-r--r--bot/seasons/christmas/__init__.py20
-rw-r--r--bot/seasons/easter/__init__.py16
-rw-r--r--bot/seasons/easter/egg_facts.py15
-rw-r--r--bot/seasons/evergreen/__init__.py17
-rw-r--r--bot/seasons/evergreen/error_handler.py4
-rw-r--r--bot/seasons/halloween/__init__.py13
-rw-r--r--bot/seasons/halloween/candy_collection.py5
-rw-r--r--bot/seasons/halloween/halloween_facts.py9
-rw-r--r--bot/seasons/halloween/spookyreact.py4
-rw-r--r--bot/seasons/pride/__init__.py15
-rw-r--r--bot/seasons/pride/pride_facts.py13
-rw-r--r--bot/seasons/season.py560
-rw-r--r--bot/seasons/valentines/__init__.py15
-rw-r--r--bot/seasons/wildcard/__init__.py13
-rw-r--r--bot/utils/persist.py2
21 files changed, 692 insertions, 688 deletions
diff --git a/bot/__main__.py b/bot/__main__.py
index a169257f..780c8c4d 100644
--- a/bot/__main__.py
+++ b/bot/__main__.py
@@ -3,10 +3,16 @@ import logging
from bot.bot import bot
from bot.constants import Client, STAFF_ROLES, WHITELISTED_CHANNELS
from bot.decorators import in_channel_check
+from bot.seasons import get_extensions
log = logging.getLogger(__name__)
bot.add_check(in_channel_check(*WHITELISTED_CHANNELS, bypass_roles=STAFF_ROLES))
+
+for ext in get_extensions():
+ bot.load_extension(ext)
+
+bot.load_extension("bot.branding")
bot.load_extension("bot.help")
-bot.load_extension("bot.seasons")
+
bot.run(Client.token)
diff --git a/bot/bot.py b/bot/bot.py
index 8b389b6a..c6ec3357 100644
--- a/bot/bot.py
+++ b/bot/bot.py
@@ -1,8 +1,12 @@
+import asyncio
+import contextlib
import logging
import socket
from traceback import format_exc
-from typing import List
+from typing import List, Optional
+import async_timeout
+import discord
from aiohttp import AsyncResolver, ClientSession, TCPConnector
from discord import DiscordException, Embed
from discord.ext import commands
@@ -28,7 +32,7 @@ class SeasonalBot(commands.Bot):
# Unload all cogs
extensions = list(self.extensions.keys())
for extension in extensions:
- if extension not in ["bot.seasons", "bot.help"]: # We shouldn't unload the manager and help.
+ if extension not in ["bot.branding", "bot.help"]: # We shouldn't unload the manager and help.
self.unload_extension(extension)
# Load in the list of cogs that was passed in here
@@ -63,5 +67,115 @@ class SeasonalBot(commands.Bot):
else:
await super().on_command_error(context, exception)
+ @property
+ def member(self) -> Optional[discord.Member]:
+ """Retrieves the guild member object for the bot."""
+ guild = bot.get_guild(Client.guild)
+ if not guild:
+ return None
+ return guild.me
+
+ async def set_avatar(self, url: str) -> bool:
+ """Sets the bot's avatar based on a URL."""
+ # Track old avatar hash for later comparison
+ old_avatar = bot.user.avatar
+
+ image = await self._fetch_image(url)
+ with contextlib.suppress(discord.HTTPException, asyncio.TimeoutError):
+ async with async_timeout.timeout(5):
+ await bot.user.edit(avatar=image)
+
+ if bot.user.avatar != old_avatar:
+ log.debug(f"Avatar changed to {url}")
+ return True
+
+ log.warning(f"Changing avatar failed: {url}")
+ return False
+
+ async def set_banner(self, url: str) -> bool:
+ """Sets the guild's banner based on the provided `url`."""
+ guild = bot.get_guild(Client.guild)
+ old_banner = guild.banner
+
+ image = await self._fetch_image(url)
+ with contextlib.suppress(discord.HTTPException, asyncio.TimeoutError):
+ async with async_timeout.timeout(5):
+ await guild.edit(banner=image)
+
+ new_banner = bot.get_guild(Client.guild).banner
+ if new_banner != old_banner:
+ log.debug(f"Banner changed to {url}")
+ return True
+
+ log.warning(f"Changing banner failed: {url}")
+ return False
+
+ async def set_icon(self, url: str) -> bool:
+ """Sets the guild's icon based on a URL."""
+ guild = bot.get_guild(Client.guild)
+ # Track old icon hash for later comparison
+ old_icon = guild.icon
+
+ image = await self._fetch_image(url)
+ with contextlib.suppress(discord.HTTPException, asyncio.TimeoutError):
+ async with async_timeout.timeout(5):
+ await guild.edit(icon=image)
+
+ new_icon = bot.get_guild(Client.guild).icon
+ if new_icon != old_icon:
+ log.debug(f"Icon changed to {url}")
+ return True
+
+ log.warning(f"Changing icon failed: {url}")
+ return False
+
+ async def _fetch_image(self, url: str) -> bytes:
+ """Retrieve an image based on a URL."""
+ log.debug(f"Getting image from: {url}")
+ async with self.http_session.get(url) as resp:
+ return await resp.read()
+
+ async def set_username(self, new_name: str, nick_only: bool = False) -> Optional[bool]:
+ """
+ Set the bot username and/or nickname to given new name.
+
+ Returns True/False based on success, or None if nickname fallback also failed.
+ """
+ old_username = self.user.name
+
+ if nick_only:
+ return await self.set_nickname(new_name)
+
+ if old_username == new_name:
+ # since the username is correct, make sure nickname is removed
+ return await self.set_nickname()
+
+ log.debug(f"Changing username to {new_name}")
+ with contextlib.suppress(discord.HTTPException):
+ await bot.user.edit(username=new_name, nick=None)
+
+ if not new_name == self.member.display_name:
+ # name didn't change, try to changing nickname as fallback
+ if await self.set_nickname(new_name):
+ log.warning(f"Changing username failed, changed nickname instead.")
+ return False
+ log.warning(f"Changing username and nickname failed.")
+ return None
+
+ return True
+
+ async def set_nickname(self, new_name: str = None) -> bool:
+ """Set the bot nickname in the main guild."""
+ old_display_name = self.member.display_name
+
+ if old_display_name == new_name:
+ return False
+
+ log.debug(f"Changing nickname to {new_name}")
+ with contextlib.suppress(discord.HTTPException):
+ await self.member.edit(nick=new_name)
+
+ return not old_display_name == self.member.display_name
+
bot = SeasonalBot(command_prefix=Client.prefix)
diff --git a/bot/branding.py b/bot/branding.py
new file mode 100644
index 00000000..a9b6234c
--- /dev/null
+++ b/bot/branding.py
@@ -0,0 +1,315 @@
+import asyncio
+import itertools
+import logging
+import random
+import typing as t
+from datetime import datetime, time, timedelta
+
+import discord
+from discord.ext import commands
+
+from bot.bot import SeasonalBot
+from bot.constants import Client, MODERATION_ROLES
+from bot.decorators import with_role
+from bot.seasons import SeasonBase, get_current_season, get_season
+
+log = logging.getLogger(__name__)
+log.setLevel(logging.DEBUG)
+
+BRANDING_URL = "https://api.github.com/repos/python-discord/branding/contents"
+
+HEADERS = {"Accept": "application/vnd.github.v3+json"} # Ensure we use API v3
+PARAMS = {"ref": "seasonal-structure"} # Target branch
+
+
+class GithubFile(t.NamedTuple):
+ """
+ Represents a remote file on Github.
+
+ The sha hash is kept so that we can determine that a file has changed,
+ despite its filename remaining unchanged.
+ """
+
+ download_url: str
+ sha: str
+
+
+async def pretty_files(files: t.Iterable[GithubFile]) -> str:
+ """
+ Provide a human-friendly representation of `files`.
+
+ In practice, this retrieves the filename from each file's url,
+ and joins them on a comma.
+ """
+ return ", ".join(file.download_url.split("/")[-1] for file in files)
+
+
+async def seconds_until_midnight() -> float:
+ """
+ Give the amount of seconds needed to wait until the next-up UTC midnight.
+
+ The exact `midnight` moment is actually delayed to 5 seconds after, in order
+ to avoid potential problems due to imprecise sleep.
+ """
+ now = datetime.utcnow()
+ tomorrow = now + timedelta(days=1)
+ midnight = datetime.combine(tomorrow, time(second=5))
+
+ return (midnight - now).total_seconds()
+
+
+class BrandingManager(commands.Cog):
+ """
+ Manages the guild's branding.
+
+ The `daemon` task automatically manages branding across seasons. See its docstring
+ for further explanation of the automated behaviour.
+
+ If necessary, or for testing purposes, the Cog can be manually controlled
+ via the `branding` command group.
+ """
+
+ current_season: t.Type[SeasonBase]
+
+ banner: t.Optional[GithubFile]
+ avatar: t.Optional[GithubFile]
+
+ available_icons: t.List[GithubFile]
+ remaining_icons: t.List[GithubFile]
+
+ should_cycle: t.Iterator
+
+ daemon: asyncio.Task
+
+ def __init__(self, bot: SeasonalBot) -> None:
+ """
+ Assign safe default values on init.
+
+ At this point, we don't have information about currently available branding.
+ Most of these attributes will be overwritten once the daemon connects.
+ """
+ self.bot = bot
+ self.current_season = get_current_season()
+
+ self.banner = None
+ self.avatar = None
+
+ self.should_cycle = itertools.cycle([False])
+
+ self.available_icons = []
+ self.remaining_icons = []
+
+ self.daemon = self.bot.loop.create_task(self._daemon_func())
+
+ async def _daemon_func(self) -> None:
+ """
+ Manage all automated behaviour of the BrandingManager cog.
+
+ Once a day, the daemon will perform the following tasks:
+ - Update `current_season`
+ - Poll Github API to see if the available branding for `current_season` has changed
+ - Update assets if changes are detected (banner, guild icon, bot avatar, bot nickname)
+ - Check whether it's time to cycle guild icons
+
+ The daemon awakens on start-up, then periodically at the time given by `seconds_until_midnight`.
+ """
+ await self.bot.wait_until_ready()
+
+ while True:
+ self.current_season = get_current_season()
+ branding_changed = await self.refresh()
+
+ if branding_changed:
+ await self.apply()
+
+ elif next(self.should_cycle):
+ await self.cycle()
+
+ await asyncio.sleep(await seconds_until_midnight())
+
+ async def _info_embed(self) -> discord.Embed:
+ """Make an informative embed representing current state."""
+ info_embed = discord.Embed(
+ title=self.current_season.season_name,
+ description=f"Active in {', '.join(m.name for m in self.current_season.months)}",
+ ).add_field(
+ name="Banner",
+ value=f"{self.banner is not None}",
+ ).add_field(
+ name="Avatar",
+ value=f"{self.avatar is not None}",
+ ).add_field(
+ name="Available icons",
+ value=await pretty_files(self.available_icons) or "Empty",
+ inline=False,
+ )
+
+ # Only add information about next-up icons if we're cycling in this season
+ if len(self.remaining_icons) > 1:
+ info_embed.add_field(
+ name=f"Queue (frequency: {Client.icon_cycle_frequency})",
+ value=await pretty_files(self.remaining_icons) or "Empty",
+ inline=False,
+ )
+
+ return info_embed
+
+ async def _reset_remaining_icons(self) -> None:
+ """Set `remaining_icons` to a shuffled copy of `available_icons`."""
+ self.remaining_icons = random.sample(self.available_icons, k=len(self.available_icons))
+
+ async def _reset_should_cycle(self) -> None:
+ """
+ Reset the `should_cycle` counter based on configured frequency.
+
+ Counter will always yield False if either holds:
+ - Client.icon_cycle_frequency is falsey
+ - There are fewer than 2 available icons for current season
+
+ Cycling can be easily turned off, and we prevent re-uploading the same icon repeatedly.
+ """
+ if len(self.available_icons) > 1 and Client.icon_cycle_frequency:
+ wait_period = [False] * (Client.icon_cycle_frequency - 1)
+ counter = itertools.cycle(wait_period + [True])
+ else:
+ counter = itertools.cycle([False])
+
+ self.should_cycle = counter
+
+ async def _get_files(self, path: str) -> t.Dict[str, GithubFile]:
+ """
+ Poll `path` in branding repo for information about present files.
+
+ Return dict mapping from filename to corresponding `GithubFile` instance.
+ """
+ url = f"{BRANDING_URL}/{path}"
+ async with self.bot.http_session.get(url, headers=HEADERS, params=PARAMS) as resp:
+ directory = await resp.json()
+
+ return {
+ file["name"]: GithubFile(file["download_url"], file["sha"])
+ for file in directory
+ }
+
+ async def refresh(self) -> bool:
+ """
+ Poll Github API to refresh currently available icons.
+
+ Return True if the branding has changed. This will be the case when we enter
+ a new season, or when something changes in the current seasons's directory
+ in the branding repository.
+ """
+ old_branding = (self.banner, self.avatar, self.available_icons)
+
+ seasonal_dir = await self._get_files(self.current_season.branding_path)
+ self.banner = seasonal_dir.get("banner.png")
+ self.avatar = seasonal_dir.get("bot_icon.png")
+
+ if "server_icons" in seasonal_dir:
+ icons_dir = await self._get_files(f"{self.current_season.branding_path}/server_icons")
+ self.available_icons = list(icons_dir.values())
+ else:
+ self.available_icons = []
+
+ branding_changed = old_branding != (self.banner, self.avatar, self.available_icons)
+ log.info(f"New branding detected: {branding_changed}")
+
+ if branding_changed:
+ await self._reset_remaining_icons()
+ await self._reset_should_cycle()
+
+ return branding_changed
+
+ async def cycle(self) -> bool:
+ """Apply the next-up server icon."""
+ if not self.available_icons:
+ log.info("Cannot cycle: no icons for this season")
+ return False
+
+ if not self.remaining_icons:
+ await self._reset_remaining_icons()
+ log.info(f"Set remaining icons: {await pretty_files(self.remaining_icons)}")
+
+ next_up, *self.remaining_icons = self.remaining_icons
+ # await self.bot.set_icon(next_up.download_url)
+ log.info(f"Applying icon: {next_up}")
+
+ return True
+
+ async def apply(self) -> None:
+ """
+ Apply current branding to the guild and bot.
+
+ This delegates to the bot instance to do all the work. We only provide download urls
+ for available assets. Assets unavailable in the branding repo will be ignored.
+ """
+ if self.banner is not None:
+ # await self.bot.set_banner(self.banner.download_url)
+ log.info(f"Applying banner: {self.banner.download_url}")
+
+ if self.avatar is not None:
+ # await self.bot.set_avatar(self.avatar.download_url)
+ log.info(f"Applying avatar: {self.avatar.download_url}")
+
+ # await self.bot.set_nickname(self.current_season.bot_name)
+ log.info(f"Applying nickname: {self.current_season.bot_name}")
+
+ await self.cycle()
+
+ @with_role(*MODERATION_ROLES)
+ @commands.group(name="branding")
+ async def branding_cmds(self, ctx: commands.Context) -> None:
+ """Group for commands allowing manual control of the `SeasonManager` cog."""
+ if not ctx.invoked_subcommand:
+ await self.branding_info(ctx)
+
+ @branding_cmds.command(name="info", aliases=["status"])
+ async def branding_info(self, ctx: commands.Context) -> None:
+ """Provide an information embed representing current branding situation."""
+ await ctx.send(embed=await self._info_embed())
+
+ @branding_cmds.command(name="refresh")
+ async def branding_refresh(self, ctx: commands.Context) -> None:
+ """Poll Github API to refresh currently available branding, dispatch info embed."""
+ async with ctx.typing():
+ await self.refresh()
+ await self.branding_info(ctx)
+
+ @branding_cmds.command(name="cycle")
+ async def branding_cycle(self, ctx: commands.Context) -> None:
+ """Force cycle guild icon."""
+ async with ctx.typing():
+ success = self.cycle()
+ await ctx.send("Icon cycle successful" if success else "Icon cycle failed")
+
+ @branding_cmds.command(name="apply")
+ async def branding_apply(self, ctx: commands.Context) -> None:
+ """Force apply current branding."""
+ async with ctx.typing():
+ await self.apply()
+ await ctx.send("Branding applied")
+
+ @branding_cmds.command(name="set")
+ async def branding_set(self, ctx: commands.Context, season_name: t.Optional[str] = None) -> None:
+ """Manually set season if `season_name` is provided, otherwise reset to current."""
+ if season_name is None:
+ new_season = get_current_season()
+ else:
+ new_season = get_season(season_name)
+ if new_season is None:
+ raise commands.BadArgument("No such season exists")
+
+ if self.current_season is not new_season:
+ async with ctx.typing():
+ self.current_season = new_season
+ await self.refresh()
+ await self.apply()
+ await self.branding_info(ctx)
+ else:
+ await ctx.send(f"Season {self.current_season.season_name} already active")
+
+
+def setup(bot: SeasonalBot) -> None:
+ """Load BrandingManager cog."""
+ bot.add_cog(BrandingManager(bot))
+ log.info("BrandingManager cog loaded")
diff --git a/bot/constants.py b/bot/constants.py
index 26cc9715..d99da892 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -1,11 +1,12 @@
+import enum
import logging
+from datetime import datetime
from os import environ
from typing import NamedTuple
-from datetime import datetime
__all__ = (
"bookmark_icon_url",
- "AdventOfCode", "Channels", "Client", "Colours", "Emojis", "Hacktoberfest", "Roles", "Tokens",
+ "AdventOfCode", "Channels", "Client", "Colours", "Emojis", "Hacktoberfest", "Month", "Roles", "Tokens",
"WHITELISTED_CHANNELS", "STAFF_ROLES", "MODERATION_ROLES",
"POSITIVE_REPLIES", "NEGATIVE_REPLIES", "ERROR_REPLIES",
)
@@ -68,7 +69,7 @@ class Client(NamedTuple):
token = environ.get("SEASONALBOT_TOKEN")
debug = environ.get("SEASONALBOT_DEBUG", "").lower() == "true"
season_override = environ.get("SEASON_OVERRIDE")
- icon_cycle_frequency = 3 # N days to wait between cycling server icons within a single season
+ icon_cycle_frequency = 3 # 0: never, 1: every day, 2: every other day, ...
class Colours:
@@ -116,6 +117,21 @@ class Hacktoberfest(NamedTuple):
voice_id = 514420006474219521
+class Month(enum.IntEnum):
+ january = 1
+ february = 2
+ march = 3
+ april = 4
+ may = 5
+ june = 6
+ july = 7
+ august = 8
+ september = 9
+ october = 10
+ november = 11
+ december = 12
+
+
class Roles(NamedTuple):
admin = int(environ.get("SEASONALBOT_ADMIN_ROLE_ID", 267628507062992896))
announcements = 463658397560995840
diff --git a/bot/decorators.py b/bot/decorators.py
index 58f67a15..874c811b 100644
--- a/bot/decorators.py
+++ b/bot/decorators.py
@@ -1,7 +1,10 @@
+import asyncio
+import functools
import logging
import random
import typing
from asyncio import Lock
+from datetime import datetime
from functools import wraps
from weakref import WeakValueDictionary
@@ -9,7 +12,9 @@ from discord import Colour, Embed
from discord.ext import commands
from discord.ext.commands import CheckFailure, Context
-from bot.constants import ERROR_REPLIES
+from bot.constants import ERROR_REPLIES, Month
+
+ONE_DAY = 24 * 60 * 60
log = logging.getLogger(__name__)
@@ -20,7 +25,93 @@ class InChannelCheckFailure(CheckFailure):
pass
-def with_role(*role_ids: int) -> bool:
+class InMonthCheckFailure(CheckFailure):
+ """Check failure for when a command is invoked outside of its allowed month."""
+
+ pass
+
+
+def seasonal_task(*allowed_months: Month, sleep_time: float = ONE_DAY) -> typing.Callable:
+ """
+ Perform the decorated method periodically in `allowed_months`.
+
+ This provides a convenience wrapper to avoid code repetition where some task shall
+ perform an operation repeatedly in a constant interval, but only in specific months.
+
+ The decorated function will be called once every `sleep_time` seconds while
+ the current UTC month is in `allowed_months`. Sleep time defaults to 24 hours.
+ """
+ def decorator(task_body: typing.Callable) -> typing.Callable:
+ @functools.wraps(task_body)
+ async def decorated_task(self: commands.Cog, *args, **kwargs) -> None:
+ """
+ Call `task_body` once every `sleep_time` seconds in `allowed_months`.
+
+ We assume `self` to be a Cog subclass instance carrying a `bot` attr.
+ As some tasks may rely on the client's cache to be ready, we delegate
+ to the bot to wait until it's ready.
+ """
+ await self.bot.wait_until_ready()
+ log.info(f"Starting seasonal task {task_body.__qualname__} ({allowed_months})")
+
+ while True:
+ current_month = Month(datetime.utcnow().month)
+
+ if current_month in allowed_months:
+ await task_body(self, *args, **kwargs)
+ else:
+ log.debug(f"Seasonal task {task_body.__qualname__} sleeps in {current_month.name}")
+
+ await asyncio.sleep(sleep_time)
+ return decorated_task
+ return decorator
+
+
+def in_month_listener(*allowed_months: Month) -> typing.Callable:
+ """
+ Shield a listener from being invoked outside of `allowed_months`.
+
+ The check is performed against current UTC month.
+ """
+ def decorator(listener: typing.Callable) -> typing.Callable:
+ @functools.wraps(listener)
+ async def guarded_listener(*args, **kwargs) -> None:
+ """Wrapped listener will abort if not in allowed month."""
+ current_month = Month(datetime.utcnow().month)
+
+ if current_month in allowed_months:
+ # Propagate return value although it should always be None
+ return await listener(*args, **kwargs)
+ else:
+ log.debug(f"Guarded {listener.__qualname__} from invoking in {current_month.name}")
+ return guarded_listener
+ return decorator
+
+
+def in_month(*allowed_months: Month) -> typing.Callable:
+ """
+ Check whether the command was invoked in one of `enabled_months`.
+
+ Uses the current UTC month at the time of running the predicate.
+ """
+ async def predicate(ctx: Context) -> bool:
+ current_month = datetime.utcnow().month
+ can_run = current_month in allowed_months
+
+ human_months = ", ".join(m.name for m in allowed_months)
+ log.debug(
+ f"Command '{ctx.command}' is locked to months {human_months}. "
+ f"Invoking it in month {current_month} is {'allowed' if can_run else 'disallowed'}."
+ )
+ if can_run:
+ return True
+ else:
+ raise InMonthCheckFailure(f"Command can only be used in {human_months}")
+
+ return commands.check(predicate)
+
+
+def with_role(*role_ids: int) -> typing.Callable:
"""Check to see whether the invoking user has any of the roles specified in role_ids."""
async def predicate(ctx: Context) -> bool:
if not ctx.guild: # Return False in a DM
@@ -43,7 +134,7 @@ def with_role(*role_ids: int) -> bool:
return commands.check(predicate)
-def without_role(*role_ids: int) -> bool:
+def without_role(*role_ids: int) -> typing.Callable:
"""Check whether the invoking user does not have all of the roles specified in role_ids."""
async def predicate(ctx: Context) -> bool:
if not ctx.guild: # Return False in a DM
diff --git a/bot/seasons/__init__.py b/bot/seasons/__init__.py
index 7faf9164..ae9ff61a 100644
--- a/bot/seasons/__init__.py
+++ b/bot/seasons/__init__.py
@@ -1,14 +1,99 @@
import logging
+import pkgutil
+from datetime import datetime
+from pathlib import Path
+from typing import List, Optional, Set, Type
-from discord.ext import commands
+from bot.constants import Month
-from bot.seasons.season import SeasonBase, SeasonManager, get_season
-
-__all__ = ("SeasonBase", "get_season")
+__all__ = ("SeasonBase", "get_seasons", "get_extensions", "get_current_season", "get_season")
log = logging.getLogger(__name__)
-def setup(bot: commands.Bot) -> None:
- bot.add_cog(SeasonManager(bot))
- log.info("SeasonManager cog loaded")
+def get_seasons() -> List[str]:
+ """Returns all the Season objects located in /bot/seasons/."""
+ seasons = []
+
+ for module in pkgutil.iter_modules([Path("bot/seasons")]):
+ if module.ispkg:
+ seasons.append(module.name)
+ return seasons
+
+
+def get_extensions() -> List[str]:
+ """
+ Give a list of dot-separated paths to all extensions.
+
+ The strings are formatted in a way such that the bot's `load_extension`
+ method can take them. Use this to load all available extensions.
+ """
+ base_path = Path("bot", "seasons")
+ extensions = []
+
+ for package in pkgutil.iter_modules([base_path]):
+
+ if package.ispkg:
+ package_path = base_path.joinpath(package.name)
+
+ for module in pkgutil.iter_modules([package_path]):
+ extensions.append(f"bot.seasons.{package.name}.{module.name}")
+ else:
+ extensions.append(f"bot.seasons.{package.name}")
+
+ return extensions
+
+
+class SeasonBase:
+ """
+ Base for Seasonal classes.
+
+ This serves as the off-season fallback for when no specific
+ seasons are active.
+
+ Seasons are 'registered' by simply by inheriting from `SeasonBase`,
+ as they are then found by looking at `__subclasses__`.
+ """
+
+ season_name: str = "Evergreen"
+ bot_name: str = "SeasonalBot"
+
+ description: str = "The default season!"
+
+ branding_path: str = "seasonal/evergreen"
+
+ months: Set[Month] = set(Month)
+
+
+def get_current_season() -> Type[SeasonBase]:
+ """Give active season, based on current UTC month."""
+ current_month = Month(datetime.utcnow().month)
+
+ active_seasons = tuple(
+ season
+ for season in SeasonBase.__subclasses__()
+ if current_month in season.months
+ )
+
+ if not active_seasons:
+ return SeasonBase
+
+ if len(active_seasons) > 1:
+ log.warning(f"Multiple active season in month {current_month.name}")
+
+ return active_seasons[0]
+
+
+def get_season(name: str) -> Optional[Type[SeasonBase]]:
+ """
+ Give season such that its class name or its `season_name` attr match `name` (caseless).
+
+ If no such season exists, return None.
+ """
+ name = name.casefold()
+
+ for season in [SeasonBase] + SeasonBase.__subclasses__():
+ matches = (season.__name__.casefold(), season.season_name.casefold())
+
+ if name in matches:
+ return season
diff --git a/bot/seasons/christmas/__init__.py b/bot/seasons/christmas/__init__.py
index 4287efb7..b8fb19f0 100644
--- a/bot/seasons/christmas/__init__.py
+++ b/bot/seasons/christmas/__init__.py
@@ -1,6 +1,4 @@
-import datetime
-
-from bot.constants import Colours
+from bot.constants import Month
from bot.seasons import SeasonBase
@@ -15,19 +13,9 @@ class Christmas(SeasonBase):
enjoy this festive season!
"""
- name = "christmas"
+ season_name = "Festive season"
bot_name = "Merrybot"
- greeting = "Happy Holidays!"
-
- start_date = "01/12"
- end_date = "01/01"
- colour = Colours.dark_green
- icon = (
- "/logos/logo_seasonal/christmas/2019/festive_512.gif",
- )
+ branding_path = "seasonal/christmas"
- @classmethod
- def end(cls) -> datetime.datetime:
- """Overload the `SeasonBase` method to account for the event ending in the next year."""
- return datetime.datetime.strptime(f"{cls.end_date}/{cls.current_year() + 1}", cls.date_format)
+ months = {Month.december}
diff --git a/bot/seasons/easter/__init__.py b/bot/seasons/easter/__init__.py
index dd60bf5c..5056bd7e 100644
--- a/bot/seasons/easter/__init__.py
+++ b/bot/seasons/easter/__init__.py
@@ -1,4 +1,4 @@
-from bot.constants import Colours
+from bot.constants import Month
from bot.seasons import SeasonBase
@@ -21,15 +21,11 @@ class Easter(SeasonBase):
will find you a task and teach you what you need to know.
"""
- name = "easter"
+ season_name = "Easter"
bot_name = "BunnyBot"
- greeting = "Happy Easter!"
- # Duration of season
- start_date = "02/04"
- end_date = "30/04"
+ description = "Bunny here, bunny there, bunny everywhere!"
- colour = Colours.pink
- icon = (
- "/logos/logo_seasonal/easter/easter.png",
- )
+ branding_path = "seasonal/easter"
+
+ months = {Month.april}
diff --git a/bot/seasons/easter/egg_facts.py b/bot/seasons/easter/egg_facts.py
index e66e25a3..f61f9da4 100644
--- a/bot/seasons/easter/egg_facts.py
+++ b/bot/seasons/easter/egg_facts.py
@@ -1,4 +1,3 @@
-import asyncio
import logging
import random
from json import load
@@ -7,9 +6,8 @@ from pathlib import Path
import discord
from discord.ext import commands
-from bot.constants import Channels
-from bot.constants import Colours
-
+from bot.constants import Channels, Colours, Month
+from bot.decorators import seasonal_task
log = logging.getLogger(__name__)
@@ -25,6 +23,8 @@ class EasterFacts(commands.Cog):
self.bot = bot
self.facts = self.load_json()
+ self.daily_fact_task = self.bot.loop.create_task(self.send_egg_fact_daily())
+
@staticmethod
def load_json() -> dict:
"""Load a list of easter egg facts from the resource JSON file."""
@@ -32,13 +32,11 @@ class EasterFacts(commands.Cog):
with p.open(encoding="utf8") as f:
return load(f)
+ @seasonal_task(Month.april)
async def send_egg_fact_daily(self) -> None:
"""A background task that sends an easter egg fact in the event channel everyday."""
channel = self.bot.get_channel(Channels.seasonalbot_commands)
- while True:
- embed = self.make_embed()
- await channel.send(embed=embed)
- await asyncio.sleep(24 * 60 * 60)
+ await channel.send(embed=self.make_embed())
@commands.command(name='eggfact', aliases=['fact'])
async def easter_facts(self, ctx: commands.Context) -> None:
@@ -57,6 +55,5 @@ class EasterFacts(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Easter Egg facts cog load."""
- bot.loop.create_task(EasterFacts(bot).send_egg_fact_daily())
bot.add_cog(EasterFacts(bot))
log.info("EasterFacts cog loaded")
diff --git a/bot/seasons/evergreen/__init__.py b/bot/seasons/evergreen/__init__.py
index b3d0dc63..e69de29b 100644
--- a/bot/seasons/evergreen/__init__.py
+++ b/bot/seasons/evergreen/__init__.py
@@ -1,17 +0,0 @@
-from bot.seasons import SeasonBase
-
-
-class Evergreen(SeasonBase):
- """Evergreen Seasonal event attributes."""
-
- bot_icon = "/logos/logo_seasonal/evergreen/logo_evergreen.png"
- icon = (
- "/logos/logo_animated/heartbeat/heartbeat_512.gif",
- "/logos/logo_animated/spinner/spinner_512.gif",
- "/logos/logo_animated/tongues/tongues_512.gif",
- "/logos/logo_animated/winky/winky_512.gif",
- "/logos/logo_animated/jumper/jumper_512.gif",
- "/logos/logo_animated/apple/apple_512.gif",
- "/logos/logo_animated/blinky/blinky_512.gif",
- "/logos/logo_animated/runner/runner_512.gif",
- )
diff --git a/bot/seasons/evergreen/error_handler.py b/bot/seasons/evergreen/error_handler.py
index 0d8bb0bb..ba6ca5ec 100644
--- a/bot/seasons/evergreen/error_handler.py
+++ b/bot/seasons/evergreen/error_handler.py
@@ -7,7 +7,7 @@ from discord import Embed, Message
from discord.ext import commands
from bot.constants import Colours, ERROR_REPLIES, NEGATIVE_REPLIES
-from bot.decorators import InChannelCheckFailure
+from bot.decorators import InChannelCheckFailure, InMonthCheckFailure
log = logging.getLogger(__name__)
@@ -55,7 +55,7 @@ class CommandErrorHandler(commands.Cog):
if isinstance(error, commands.CommandNotFound):
return
- if isinstance(error, InChannelCheckFailure):
+ if isinstance(error, (InChannelCheckFailure, InMonthCheckFailure)):
await ctx.send(embed=self.error_embed(str(error), NEGATIVE_REPLIES), delete_after=7.5)
return
diff --git a/bot/seasons/halloween/__init__.py b/bot/seasons/halloween/__init__.py
index c81879d7..b20da9ac 100644
--- a/bot/seasons/halloween/__init__.py
+++ b/bot/seasons/halloween/__init__.py
@@ -1,4 +1,4 @@
-from bot.constants import Colours
+from bot.constants import Month
from bot.seasons import SeasonBase
@@ -11,14 +11,9 @@ class Halloween(SeasonBase):
make sure to update this docstring accordingly.
"""
- name = "halloween"
+ season_name = "Halloween"
bot_name = "NeonBot"
- greeting = "Happy Halloween!"
- start_date = "01/10"
- end_date = "01/11"
+ branding_path = "seasonal/halloween"
- colour = Colours.pink
- icon = (
- "/logos/logo_seasonal/hacktober/hacktoberfest.png",
- )
+ months = {Month.october}
diff --git a/bot/seasons/halloween/candy_collection.py b/bot/seasons/halloween/candy_collection.py
index 490609dd..3c65a745 100644
--- a/bot/seasons/halloween/candy_collection.py
+++ b/bot/seasons/halloween/candy_collection.py
@@ -8,7 +8,8 @@ from typing import List, Union
import discord
from discord.ext import commands
-from bot.constants import Channels
+from bot.constants import Channels, Month
+from bot.decorators import in_month_listener
log = logging.getLogger(__name__)
@@ -35,6 +36,7 @@ class CandyCollection(commands.Cog):
self.get_candyinfo[userid] = userinfo
@commands.Cog.listener()
+ @in_month_listener(Month.october)
async def on_message(self, message: discord.Message) -> None:
"""Randomly adds candy or skull reaction to non-bot messages in the Event channel."""
# make sure its a human message
@@ -56,6 +58,7 @@ class CandyCollection(commands.Cog):
return await message.add_reaction('\N{CANDY}')
@commands.Cog.listener()
+ @in_month_listener(Month.october)
async def on_reaction_add(self, reaction: discord.Reaction, user: discord.Member) -> None:
"""Add/remove candies from a person if the reaction satisfies criteria."""
message = reaction.message
diff --git a/bot/seasons/halloween/halloween_facts.py b/bot/seasons/halloween/halloween_facts.py
index 94730d9e..222768f4 100644
--- a/bot/seasons/halloween/halloween_facts.py
+++ b/bot/seasons/halloween/halloween_facts.py
@@ -8,8 +8,6 @@ from typing import Tuple
import discord
from discord.ext import commands
-from bot.constants import Channels
-
log = logging.getLogger(__name__)
SPOOKY_EMOJIS = [
@@ -33,16 +31,9 @@ class HalloweenFacts(commands.Cog):
self.bot = bot
with open(Path("bot/resources/halloween/halloween_facts.json"), "r") as file:
self.halloween_facts = json.load(file)
- self.channel = None
self.facts = list(enumerate(self.halloween_facts))
random.shuffle(self.facts)
- @commands.Cog.listener()
- async def on_ready(self) -> None:
- """Get event Channel object and initialize fact task loop."""
- self.channel = self.bot.get_channel(Channels.seasonalbot_commands)
- self.bot.loop.create_task(self._fact_publisher_task())
-
def random_fact(self) -> Tuple[int, str]:
"""Return a random fact from the loaded facts."""
return random.choice(self.facts)
diff --git a/bot/seasons/halloween/spookyreact.py b/bot/seasons/halloween/spookyreact.py
index 90b1254d..c6127298 100644
--- a/bot/seasons/halloween/spookyreact.py
+++ b/bot/seasons/halloween/spookyreact.py
@@ -4,6 +4,9 @@ import re
import discord
from discord.ext.commands import Bot, Cog
+from bot.constants import Month
+from bot.decorators import in_month_listener
+
log = logging.getLogger(__name__)
SPOOKY_TRIGGERS = {
@@ -24,6 +27,7 @@ class SpookyReact(Cog):
self.bot = bot
@Cog.listener()
+ @in_month_listener(Month.october)
async def on_message(self, ctx: discord.Message) -> None:
"""
A command to send the seasonalbot github project.
diff --git a/bot/seasons/pride/__init__.py b/bot/seasons/pride/__init__.py
index 08df2fa1..6509ac9b 100644
--- a/bot/seasons/pride/__init__.py
+++ b/bot/seasons/pride/__init__.py
@@ -1,4 +1,4 @@
-from bot.constants import Colours
+from bot.constants import Month
from bot.seasons import SeasonBase
@@ -21,16 +21,9 @@ class Pride(SeasonBase):
will find you a task and teach you what you need to know.
"""
- name = "pride"
+ season_name = "Pride"
bot_name = "ProudBot"
- greeting = "Happy Pride Month!"
- # Duration of season
- start_date = "01/06"
- end_date = "01/07"
+ branding_path = "seasonal/pride"
- # Season logo
- colour = Colours.soft_red
- icon = (
- "/logos/logo_seasonal/pride/logo_pride.png",
- )
+ months = {Month.june}
diff --git a/bot/seasons/pride/pride_facts.py b/bot/seasons/pride/pride_facts.py
index 5c19dfd0..417a49a6 100644
--- a/bot/seasons/pride/pride_facts.py
+++ b/bot/seasons/pride/pride_facts.py
@@ -1,4 +1,3 @@
-import asyncio
import json
import logging
import random
@@ -10,8 +9,8 @@ import dateutil.parser
import discord
from discord.ext import commands
-from bot.constants import Channels
-from bot.constants import Colours
+from bot.constants import Channels, Colours, Month
+from bot.decorators import seasonal_task
log = logging.getLogger(__name__)
@@ -25,18 +24,19 @@ class PrideFacts(commands.Cog):
self.bot = bot
self.facts = self.load_facts()
+ self.daily_fact_task = self.bot.loop.create_task(self.send_pride_fact_daily())
+
@staticmethod
def load_facts() -> dict:
"""Loads a dictionary of years mapping to lists of facts."""
with open(Path("bot/resources/pride/facts.json"), "r", encoding="utf-8") as f:
return json.load(f)
+ @seasonal_task(Month.june)
async def send_pride_fact_daily(self) -> None:
"""Background task to post the daily pride fact every day."""
channel = self.bot.get_channel(Channels.seasonalbot_commands)
- while True:
- await self.send_select_fact(channel, datetime.utcnow())
- await asyncio.sleep(24 * 60 * 60)
+ await self.send_select_fact(channel, datetime.utcnow())
async def send_random_fact(self, ctx: commands.Context) -> None:
"""Provides a fact from any previous day, or today."""
@@ -101,6 +101,5 @@ class PrideFacts(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Cog loader for pride facts."""
- bot.loop.create_task(PrideFacts(bot).send_pride_fact_daily())
bot.add_cog(PrideFacts(bot))
log.info("Pride facts cog loaded!")
diff --git a/bot/seasons/season.py b/bot/seasons/season.py
deleted file mode 100644
index 763a08d2..00000000
--- a/bot/seasons/season.py
+++ /dev/null
@@ -1,560 +0,0 @@
-import asyncio
-import contextlib
-import datetime
-import importlib
-import inspect
-import logging
-import pkgutil
-from pathlib import Path
-from typing import List, Optional, Tuple, Type, Union
-
-import async_timeout
-import discord
-from discord.ext import commands
-
-from bot.bot import bot
-from bot.constants import Channels, Client, Roles
-from bot.decorators import with_role
-
-log = logging.getLogger(__name__)
-
-ICON_BASE_URL = "https://raw.githubusercontent.com/python-discord/branding/master"
-
-
-def get_seasons() -> List[str]:
- """Returns all the Season objects located in /bot/seasons/."""
- seasons = []
-
- for module in pkgutil.iter_modules([Path("bot/seasons")]):
- if module.ispkg:
- seasons.append(module.name)
- return seasons
-
-
-def get_season_class(season_name: str) -> Type["SeasonBase"]:
- """Gets the season class of the season module."""
- season_lib = importlib.import_module(f"bot.seasons.{season_name}")
- class_name = season_name.replace("_", " ").title().replace(" ", "")
- return getattr(season_lib, class_name)
-
-
-def get_season(season_name: str = None, date: datetime.datetime = None) -> "SeasonBase":
- """Returns a Season object based on either a string or a date."""
- # If either both or neither are set, raise an error.
- if not bool(season_name) ^ bool(date):
- raise UserWarning("This function requires either a season or a date in order to run.")
-
- seasons = get_seasons()
-
- # Use season override if season name not provided
- if not season_name and Client.season_override:
- log.debug(f"Season override found: {Client.season_override}")
- season_name = Client.season_override
-
- # If name provided grab the specified class or fallback to evergreen.
- if season_name:
- season_name = season_name.lower()
- if season_name not in seasons:
- season_name = "evergreen"
- season_class = get_season_class(season_name)
- return season_class()
-
- # If not, we have to figure out if the date matches any of the seasons.
- seasons.remove("evergreen")
- for season_name in seasons:
- season_class = get_season_class(season_name)
- # check if date matches before returning an instance
- if season_class.is_between_dates(date):
- return season_class()
- else:
- evergreen_class = get_season_class("evergreen")
- return evergreen_class()
-
-
-class SeasonBase:
- """Base class for Seasonal classes."""
-
- name: Optional[str] = "evergreen"
- bot_name: str = "SeasonalBot"
-
- start_date: Optional[str] = None
- end_date: Optional[str] = None
- should_announce: bool = False
-
- colour: Optional[int] = None
- icon: Tuple[str, ...] = ("/logos/logo_full/logo_full.png",)
- bot_icon: Optional[str] = None
-
- date_format: str = "%d/%m/%Y"
-
- index: int = 0
-
- @staticmethod
- def current_year() -> int:
- """Returns the current year."""
- return datetime.date.today().year
-
- @classmethod
- def start(cls) -> datetime.datetime:
- """
- Returns the start date using current year and start_date attribute.
-
- If no start_date was defined, returns the minimum datetime to ensure it's always below checked dates.
- """
- if not cls.start_date:
- return datetime.datetime.min
- return datetime.datetime.strptime(f"{cls.start_date}/{cls.current_year()}", cls.date_format)
-
- @classmethod
- def end(cls) -> datetime.datetime:
- """
- Returns the start date using current year and end_date attribute.
-
- If no end_date was defined, returns the minimum datetime to ensure it's always above checked dates.
- """
- if not cls.end_date:
- return datetime.datetime.max
- return datetime.datetime.strptime(f"{cls.end_date}/{cls.current_year()}", cls.date_format)
-
- @classmethod
- def is_between_dates(cls, date: datetime.datetime) -> bool:
- """Determines if the given date falls between the season's date range."""
- return cls.start() <= date <= cls.end()
-
- @property
- def name_clean(self) -> str:
- """Return the Season's name with underscores replaced by whitespace."""
- return self.name.replace("_", " ").title()
-
- @property
- def greeting(self) -> str:
- """
- Provides a default greeting based on the season name if one wasn't defined in the season class.
-
- It's recommended to define one in most cases by overwriting this as a normal attribute in the
- inheriting class.
- """
- return f"New Season, {self.name_clean}!"
-
- async def get_icon(self, avatar: bool = False, index: int = 0) -> Tuple[bytes, str]:
- """
- Retrieve the season's icon from the branding repository using the Season's icon attribute.
-
- This also returns the relative URL path for logging purposes
- If `avatar` is True, uses optional bot-only avatar icon if present.
- Returns the data for the given `index`, defaulting to the first item.
-
- The icon attribute must provide the url path, starting from the master branch base url,
- including the starting slash.
- e.g. `/logos/logo_seasonal/valentines/loved_up.png`
- """
- icon = self.icon[index]
- if avatar and self.bot_icon:
- icon = self.bot_icon
-
- full_url = ICON_BASE_URL + icon
- log.debug(f"Getting icon from: {full_url}")
- async with bot.http_session.get(full_url) as resp:
- return (await resp.read(), icon)
-
- async def apply_username(self, *, debug: bool = False) -> Union[bool, None]:
- """
- Applies the username for the current season.
-
- Only changes nickname if `bool` is False, otherwise only changes the nickname.
-
- Returns True if it successfully changed the username.
- Returns False if it failed to change the username, falling back to nick.
- Returns None if `debug` was True and username change wasn't attempted.
- """
- guild = bot.get_guild(Client.guild)
- result = None
-
- # Change only nickname if in debug mode due to ratelimits for user edits
- if debug:
- if guild.me.display_name != self.bot_name:
- log.debug(f"Changing nickname to {self.bot_name}")
- await guild.me.edit(nick=self.bot_name)
-
- else:
- if bot.user.name != self.bot_name:
- # Attempt to change user details
- log.debug(f"Changing username to {self.bot_name}")
- with contextlib.suppress(discord.HTTPException):
- await bot.user.edit(username=self.bot_name)
-
- # Fallback on nickname if failed due to ratelimit
- if bot.user.name != self.bot_name:
- log.warning(f"Username failed to change: Changing nickname to {self.bot_name}")
- await guild.me.edit(nick=self.bot_name)
- result = False
- else:
- result = True
-
- # Remove nickname if an old one exists
- if guild.me.nick and guild.me.nick != self.bot_name:
- log.debug(f"Clearing old nickname of {guild.me.nick}")
- await guild.me.edit(nick=None)
-
- return result
-
- async def apply_avatar(self) -> bool:
- """
- Applies the avatar for the current season.
-
- Returns True if successful.
- """
- # Track old avatar hash for later comparison
- old_avatar = bot.user.avatar
-
- # Attempt the change
- icon, name = await self.get_icon(avatar=True)
- log.debug(f"Changing avatar to {name}")
- with contextlib.suppress(discord.HTTPException, asyncio.TimeoutError):
- async with async_timeout.timeout(5):
- await bot.user.edit(avatar=icon)
-
- if bot.user.avatar != old_avatar:
- log.debug(f"Avatar changed to {name}")
- return True
-
- log.warning(f"Changing avatar failed: {name}")
- return False
-
- async def apply_server_icon(self) -> bool:
- """
- Applies the server icon for the current season.
-
- Returns True if was successful.
- """
- guild = bot.get_guild(Client.guild)
-
- # Track old icon hash for later comparison
- old_icon = guild.icon
-
- # Attempt the change
-
- icon, name = await self.get_icon(index=self.index)
-
- log.debug(f"Changing server icon to {name}")
-
- with contextlib.suppress(discord.HTTPException, asyncio.TimeoutError):
- async with async_timeout.timeout(5):
- await guild.edit(icon=icon, reason=f"Seasonbot Season Change: {self.name}")
-
- new_icon = bot.get_guild(Client.guild).icon
- if new_icon != old_icon:
- log.debug(f"Server icon changed to {name}")
- return True
-
- log.warning(f"Changing server icon failed: {name}")
- return False
-
- async def change_server_icon(self) -> bool:
- """
- Changes the server icon.
-
- This only has an effect when the Season's icon attribute is a list, in which it cycles through.
- Returns True if was successful.
- """
- if len(self.icon) == 1:
- return
-
- self.index += 1
- self.index %= len(self.icon)
-
- return await self.apply_server_icon()
-
- async def announce_season(self) -> None:
- """
- Announces a change in season in the announcement channel.
-
- Auto-announcement is configured by the `should_announce` `SeasonBase` attribute
- """
- # Short circuit if the season had disabled automatic announcements
- if not self.should_announce:
- log.debug(f"Season changed without announcement: {self.name}")
- return
-
- guild = bot.get_guild(Client.guild)
- channel = guild.get_channel(Channels.announcements)
- mention = f"<@&{Roles.announcements}>"
-
- # Build cog info output
- doc = inspect.getdoc(self)
- announce = "\n\n".join(l.replace("\n", " ") for l in doc.split("\n\n"))
-
- # No announcement message found
- if not doc:
- return
-
- embed = discord.Embed(description=f"{announce}\n\n", colour=self.colour or guild.me.colour)
- embed.set_author(name=self.greeting)
-
- if self.icon:
- embed.set_image(url=ICON_BASE_URL+self.icon[0])
-
- # Find any seasonal commands
- cogs = []
- for cog in bot.cogs.values():
- if "evergreen" in cog.__module__:
- continue
- cog_name = type(cog).__name__
- if cog_name != "SeasonManager":
- cogs.append(cog_name)
-
- if cogs:
- def cog_name(cog: commands.Cog) -> str:
- return type(cog).__name__
-
- cog_info = []
- for cog in sorted(cogs, key=cog_name):
- doc = inspect.getdoc(bot.get_cog(cog))
- if doc:
- cog_info.append(f"**{cog}**\n*{doc}*")
- else:
- cog_info.append(f"**{cog}**")
-
- cogs_text = "\n".join(cog_info)
- embed.add_field(name="New Command Categories", value=cogs_text)
- embed.set_footer(text="To see the new commands, use .help Category")
-
- await channel.send(mention, embed=embed)
-
- async def load(self) -> None:
- """
- Loads extensions, bot name and avatar, server icon and announces new season.
-
- If in debug mode, the avatar, server icon, and announcement will be skipped.
- """
- self.index = 0
- # Prepare all the seasonal cogs, and then the evergreen ones.
- extensions = []
- for ext_folder in {self.name, "evergreen"}:
- if ext_folder:
- log.info(f"Start loading extensions from seasons/{ext_folder}/")
- path = Path("bot/seasons") / ext_folder
- for ext_name in [i[1] for i in pkgutil.iter_modules([path])]:
- extensions.append(f"bot.seasons.{ext_folder}.{ext_name}")
-
- # Finally we can load all the cogs we've prepared.
- bot.load_extensions(extensions)
-
- # Apply seasonal elements after extensions successfully load
- username_changed = await self.apply_username(debug=Client.debug)
-
- # Avoid major changes and announcements if debug mode
- if not Client.debug:
- log.info("Applying avatar.")
- await self.apply_avatar()
- if username_changed:
- log.info("Applying server icon.")
- await self.apply_server_icon()
- log.info(f"Announcing season {self.name}.")
- await self.announce_season()
- else:
- log.info(f"Skipping server icon change due to username not being changed.")
- log.info(f"Skipping season announcement due to username not being changed.")
-
- await bot.send_log("SeasonalBot Loaded!", f"Active Season: **{self.name_clean}**")
-
-
-class SeasonManager(commands.Cog):
- """A cog for managing seasons."""
-
- def __init__(self, bot: commands.Bot):
- self.bot = bot
- self.season = get_season(date=datetime.datetime.utcnow())
- self.season_task = bot.loop.create_task(self.load_seasons())
-
- # Figure out number of seconds until a minute past midnight
- tomorrow = datetime.datetime.now() + datetime.timedelta(1)
- midnight = datetime.datetime(
- year=tomorrow.year,
- month=tomorrow.month,
- day=tomorrow.day,
- hour=0,
- minute=0,
- second=0
- )
- self.sleep_time = (midnight - datetime.datetime.now()).seconds + 60
-
- async def load_seasons(self) -> None:
- """Asynchronous timer loop to check for a new season every midnight."""
- await self.bot.wait_until_ready()
- await self.season.load()
- days_since_icon_change = 0
-
- while True:
- await asyncio.sleep(self.sleep_time) # Sleep until midnight
- self.sleep_time = 24 * 3600 # Next time, sleep for 24 hours
-
- days_since_icon_change += 1
- log.debug(f"Days since last icon change: {days_since_icon_change}")
-
- # If the season has changed, load it.
- new_season = get_season(date=datetime.datetime.utcnow())
- if new_season.name != self.season.name:
- self.season = new_season
- await self.season.load()
- days_since_icon_change = 0 # Start counting afresh for the new season
-
- # Otherwise we check whether it's time for an icon cycle within the current season
- else:
- if days_since_icon_change == Client.icon_cycle_frequency:
- await self.season.change_server_icon()
- days_since_icon_change = 0
- else:
- log.debug(f"Waiting {Client.icon_cycle_frequency - days_since_icon_change} more days to cycle icon")
-
- @with_role(Roles.moderator, Roles.admin, Roles.owner)
- @commands.command(name="season")
- async def change_season(self, ctx: commands.Context, new_season: str) -> None:
- """Changes the currently active season on the bot."""
- self.season = get_season(season_name=new_season)
- await self.season.load()
- await ctx.send(f"Season changed to {new_season}.")
-
- @with_role(Roles.moderator, Roles.admin, Roles.owner)
- @commands.command(name="seasons")
- async def show_seasons(self, ctx: commands.Context) -> None:
- """Shows the available seasons and their dates."""
- # Sort by start order, followed by lower duration
- def season_key(season_class: Type[SeasonBase]) -> Tuple[datetime.datetime, datetime.timedelta]:
- return season_class.start(), season_class.end() - datetime.datetime.max
-
- current_season = self.season.name
-
- forced_space = "\u200b "
-
- entries = []
- seasons = [get_season_class(s) for s in get_seasons()]
- for season in sorted(seasons, key=season_key):
- start = season.start_date
- end = season.end_date
- if start and not end:
- period = f"From {start}"
- elif end and not start:
- period = f"Until {end}"
- elif not end and not start:
- period = f"Always"
- else:
- period = f"{start} to {end}"
-
- # Bold period if current date matches season date range
- is_current = season.is_between_dates(datetime.datetime.utcnow())
- pdec = "**" if is_current else ""
-
- # Underline currently active season
- is_active = current_season == season.name
- sdec = "__" if is_active else ""
-
- entries.append(
- f"**{sdec}{season.__name__}:{sdec}**\n"
- f"{forced_space*3}{pdec}{period}{pdec}\n"
- )
-
- embed = discord.Embed(description="\n".join(entries), colour=ctx.guild.me.colour)
- embed.set_author(name="Seasons")
- await ctx.send(embed=embed)
-
- @with_role(Roles.moderator, Roles.admin, Roles.owner)
- @commands.group()
- async def refresh(self, ctx: commands.Context) -> None:
- """Refreshes certain seasonal elements without reloading seasons."""
- if not ctx.invoked_subcommand:
- await ctx.send_help(ctx.command)
-
- @refresh.command(name="avatar")
- async def refresh_avatar(self, ctx: commands.Context) -> None:
- """Re-applies the bot avatar for the currently loaded season."""
- # Attempt the change
- is_changed = await self.season.apply_avatar()
-
- if is_changed:
- colour = ctx.guild.me.colour
- title = "Avatar Refreshed"
- else:
- colour = discord.Colour.red()
- title = "Avatar Failed to Refresh"
-
- # Report back details
- season_name = type(self.season).__name__
- embed = discord.Embed(
- description=f"**Season:** {season_name}\n**Avatar:** {self.season.bot_icon or self.season.icon}",
- colour=colour
- )
- embed.set_author(name=title)
- embed.set_thumbnail(url=bot.user.avatar_url_as(format="png"))
- await ctx.send(embed=embed)
-
- @refresh.command(name="icon")
- async def refresh_server_icon(self, ctx: commands.Context) -> None:
- """Re-applies the server icon for the currently loaded season."""
- # Attempt the change
- is_changed = await self.season.apply_server_icon()
-
- if is_changed:
- colour = ctx.guild.me.colour
- title = "Server Icon Refreshed"
- else:
- colour = discord.Colour.red()
- title = "Server Icon Failed to Refresh"
-
- # Report back details
- season_name = type(self.season).__name__
- embed = discord.Embed(
- description=f"**Season:** {season_name}\n**Icon:** {self.season.icon}",
- colour=colour
- )
- embed.set_author(name=title)
- embed.set_thumbnail(url=bot.get_guild(Client.guild).icon_url_as(format="png"))
- await ctx.send(embed=embed)
-
- @refresh.command(name="username", aliases=("name",))
- async def refresh_username(self, ctx: commands.Context) -> None:
- """Re-applies the bot username for the currently loaded season."""
- old_username = str(bot.user)
- old_display_name = ctx.guild.me.display_name
-
- # Attempt the change
- is_changed = await self.season.apply_username()
-
- if is_changed:
- colour = ctx.guild.me.colour
- title = "Username Refreshed"
- changed_element = "Username"
- old_name = old_username
- new_name = str(bot.user)
- else:
- colour = discord.Colour.red()
-
- # If None, it's because it wasn't meant to change username
- if is_changed is None:
- title = "Nickname Refreshed"
- else:
- title = "Username Failed to Refresh"
- changed_element = "Nickname"
- old_name = old_display_name
- new_name = self.season.bot_name
-
- # Report back details
- season_name = type(self.season).__name__
- embed = discord.Embed(
- description=f"**Season:** {season_name}\n"
- f"**Old {changed_element}:** {old_name}\n"
- f"**New {changed_element}:** {new_name}",
- colour=colour
- )
- embed.set_author(name=title)
- await ctx.send(embed=embed)
-
- @with_role(Roles.moderator, Roles.admin, Roles.owner)
- @commands.command()
- async def announce(self, ctx: commands.Context) -> None:
- """Announces the currently loaded season."""
- await self.season.announce_season()
-
- def cog_unload(self) -> None:
- """Cancel season-related tasks on cog unload."""
- self.season_task.cancel()
diff --git a/bot/seasons/valentines/__init__.py b/bot/seasons/valentines/__init__.py
index 6e5d16f7..fb3d02af 100644
--- a/bot/seasons/valentines/__init__.py
+++ b/bot/seasons/valentines/__init__.py
@@ -1,4 +1,4 @@
-from bot.constants import Colours
+from bot.constants import Month
from bot.seasons import SeasonBase
@@ -9,14 +9,9 @@ class Valentines(SeasonBase):
Get yourself into the bot-commands channel and check out the new features!
"""
- name = "valentines"
- bot_name = "Tenderbot"
- greeting = "Get loved-up!"
+ season_name = "Valentines"
+ bot_name = "TenderBot"
- start_date = "01/02"
- end_date = "01/03"
+ branding_path = "seasonal/valentines"
- colour = Colours.pink
- icon = (
- "/logos/logo_seasonal/valentines/loved_up.png",
- )
+ months = {Month.february}
diff --git a/bot/seasons/wildcard/__init__.py b/bot/seasons/wildcard/__init__.py
index 354e979d..48491ce2 100644
--- a/bot/seasons/wildcard/__init__.py
+++ b/bot/seasons/wildcard/__init__.py
@@ -1,3 +1,4 @@
+from bot.constants import Month
from bot.seasons import SeasonBase
@@ -17,15 +18,7 @@ class Wildcard(SeasonBase):
TO THE EVERGREEN FOLDER!
"""
- name = "wildcard"
+ season_name = "Wildcard"
bot_name = "RetroBot"
- # Duration of season
- start_date = "01/08"
- end_date = "01/09"
-
- # Season logo
- bot_icon = "/logos/logo_seasonal/retro_gaming/logo_8bit_indexed_504.png"
- icon = (
- "/logos/logo_seasonal/retro_gaming_animated/logo_spin_plain/logo_spin_plain_504.gif",
- )
+ months = {Month.august}
diff --git a/bot/utils/persist.py b/bot/utils/persist.py
index a60a1219..3539375a 100644
--- a/bot/utils/persist.py
+++ b/bot/utils/persist.py
@@ -2,7 +2,7 @@ import sqlite3
from pathlib import Path
from shutil import copyfile
-from bot.seasons.season import get_seasons
+from bot.seasons import get_seasons
DIRECTORY = Path("data") # directory that has a persistent volume mapped to it