aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--bot/__main__.py9
-rw-r--r--bot/bot.py155
-rw-r--r--bot/constants.py107
-rw-r--r--bot/decorators.py177
-rw-r--r--bot/exts/__init__.py35
-rw-r--r--bot/exts/christmas/__init__.py0
-rw-r--r--bot/exts/christmas/adventofcode.py (renamed from bot/seasons/christmas/adventofcode.py)11
-rw-r--r--bot/exts/christmas/hanukkah_embed.py (renamed from bot/seasons/christmas/hanukkah_embed.py)6
-rw-r--r--bot/exts/easter/__init__.py0
-rw-r--r--bot/exts/easter/april_fools_vids.py (renamed from bot/seasons/easter/april_fools_vids.py)1
-rw-r--r--bot/exts/easter/avatar_easterifier.py (renamed from bot/seasons/easter/avatar_easterifier.py)1
-rw-r--r--bot/exts/easter/bunny_name_generator.py (renamed from bot/seasons/easter/bunny_name_generator.py)1
-rw-r--r--bot/exts/easter/conversationstarters.py (renamed from bot/seasons/easter/conversationstarters.py)1
-rw-r--r--bot/exts/easter/easter_riddle.py (renamed from bot/seasons/easter/easter_riddle.py)1
-rw-r--r--bot/exts/easter/egg_decorating.py (renamed from bot/seasons/easter/egg_decorating.py)1
-rw-r--r--bot/exts/easter/egg_facts.py (renamed from bot/seasons/easter/egg_facts.py)18
-rw-r--r--bot/exts/easter/egghead_quiz.py (renamed from bot/seasons/easter/egghead_quiz.py)1
-rw-r--r--bot/exts/easter/traditions.py (renamed from bot/seasons/easter/traditions.py)1
-rw-r--r--bot/exts/evergreen/8bitify.py (renamed from bot/seasons/evergreen/8bitify.py)0
-rw-r--r--bot/exts/evergreen/__init__.py0
-rw-r--r--bot/exts/evergreen/battleship.py (renamed from bot/seasons/evergreen/battleship.py)1
-rw-r--r--bot/exts/evergreen/bookmark.py (renamed from bot/seasons/evergreen/bookmark.py)5
-rw-r--r--bot/exts/evergreen/branding.py543
-rw-r--r--bot/exts/evergreen/error_handler.py (renamed from bot/seasons/evergreen/error_handler.py)10
-rw-r--r--bot/exts/evergreen/fun.py (renamed from bot/seasons/evergreen/fun.py)1
-rw-r--r--bot/exts/evergreen/game.py (renamed from bot/seasons/evergreen/game.py)4
-rw-r--r--bot/exts/evergreen/help.py (renamed from bot/help.py)5
-rw-r--r--bot/exts/evergreen/issues.py (renamed from bot/seasons/evergreen/issues.py)3
-rw-r--r--bot/exts/evergreen/magic_8ball.py (renamed from bot/seasons/evergreen/magic_8ball.py)1
-rw-r--r--bot/exts/evergreen/minesweeper.py (renamed from bot/seasons/evergreen/minesweeper.py)1
-rw-r--r--bot/exts/evergreen/movie.py (renamed from bot/seasons/evergreen/movie.py)2
-rw-r--r--bot/exts/evergreen/recommend_game.py (renamed from bot/seasons/evergreen/recommend_game.py)1
-rw-r--r--bot/exts/evergreen/reddit.py (renamed from bot/seasons/evergreen/reddit.py)4
-rw-r--r--bot/exts/evergreen/showprojects.py (renamed from bot/seasons/evergreen/showprojects.py)1
-rw-r--r--bot/exts/evergreen/snakes/__init__.py (renamed from bot/seasons/evergreen/snakes/__init__.py)3
-rw-r--r--bot/exts/evergreen/snakes/converter.py (renamed from bot/seasons/evergreen/snakes/converter.py)2
-rw-r--r--bot/exts/evergreen/snakes/snakes_cog.py (renamed from bot/seasons/evergreen/snakes/snakes_cog.py)6
-rw-r--r--bot/exts/evergreen/snakes/utils.py (renamed from bot/seasons/evergreen/snakes/utils.py)0
-rw-r--r--bot/exts/evergreen/space.py (renamed from bot/seasons/evergreen/space.py)0
-rw-r--r--bot/exts/evergreen/speedrun.py (renamed from bot/seasons/evergreen/speedrun.py)1
-rw-r--r--bot/exts/evergreen/trivia_quiz.py (renamed from bot/seasons/evergreen/trivia_quiz.py)1
-rw-r--r--bot/exts/evergreen/uptime.py (renamed from bot/seasons/evergreen/uptime.py)1
-rw-r--r--bot/exts/halloween/8ball.py (renamed from bot/seasons/halloween/8ball.py)1
-rw-r--r--bot/exts/halloween/__init__.py0
-rw-r--r--bot/exts/halloween/candy_collection.py (renamed from bot/seasons/halloween/candy_collection.py)7
-rw-r--r--bot/exts/halloween/hacktober-issue-finder.py (renamed from bot/seasons/halloween/hacktober-issue-finder.py)5
-rw-r--r--bot/exts/halloween/hacktoberstats.py (renamed from bot/seasons/halloween/hacktoberstats.py)9
-rw-r--r--bot/exts/halloween/halloween_facts.py (renamed from bot/seasons/halloween/halloween_facts.py)10
-rw-r--r--bot/exts/halloween/halloweenify.py (renamed from bot/seasons/halloween/halloweenify.py)1
-rw-r--r--bot/exts/halloween/monsterbio.py (renamed from bot/seasons/halloween/monsterbio.py)1
-rw-r--r--bot/exts/halloween/monstersurvey.py (renamed from bot/seasons/halloween/monstersurvey.py)1
-rw-r--r--bot/exts/halloween/scarymovie.py (renamed from bot/seasons/halloween/scarymovie.py)1
-rw-r--r--bot/exts/halloween/spookyavatar.py (renamed from bot/seasons/halloween/spookyavatar.py)1
-rw-r--r--bot/exts/halloween/spookygif.py (renamed from bot/seasons/halloween/spookygif.py)1
-rw-r--r--bot/exts/halloween/spookyrating.py (renamed from bot/seasons/halloween/spookyrating.py)1
-rw-r--r--bot/exts/halloween/spookyreact.py (renamed from bot/seasons/halloween/spookyreact.py)5
-rw-r--r--bot/exts/halloween/spookysound.py (renamed from bot/seasons/halloween/spookysound.py)1
-rw-r--r--bot/exts/halloween/timeleft.py (renamed from bot/seasons/halloween/timeleft.py)1
-rw-r--r--bot/exts/pride/__init__.py0
-rw-r--r--bot/exts/pride/drag_queen_name.py (renamed from bot/seasons/pride/drag_queen_name.py)1
-rw-r--r--bot/exts/pride/pride_anthem.py (renamed from bot/seasons/pride/pride_anthem.py)1
-rw-r--r--bot/exts/pride/pride_avatar.py (renamed from bot/seasons/pride/pride_avatar.py)1
-rw-r--r--bot/exts/pride/pride_facts.py (renamed from bot/seasons/pride/pride_facts.py)16
-rw-r--r--bot/exts/valentines/__init__.py0
-rw-r--r--bot/exts/valentines/be_my_valentine.py (renamed from bot/seasons/valentines/be_my_valentine.py)10
-rw-r--r--bot/exts/valentines/lovecalculator.py (renamed from bot/seasons/valentines/lovecalculator.py)1
-rw-r--r--bot/exts/valentines/movie_generator.py (renamed from bot/seasons/valentines/movie_generator.py)1
-rw-r--r--bot/exts/valentines/myvalenstate.py (renamed from bot/seasons/valentines/myvalenstate.py)1
-rw-r--r--bot/exts/valentines/pickuplines.py (renamed from bot/seasons/valentines/pickuplines.py)1
-rw-r--r--bot/exts/valentines/savethedate.py (renamed from bot/seasons/valentines/savethedate.py)1
-rw-r--r--bot/exts/valentines/valentine_zodiac.py (renamed from bot/seasons/valentines/valentine_zodiac.py)1
-rw-r--r--bot/exts/valentines/whoisvalentine.py (renamed from bot/seasons/valentines/whoisvalentine.py)1
-rw-r--r--bot/resources/evergreen/branding.json3
-rw-r--r--bot/seasons.py181
-rw-r--r--bot/seasons/__init__.py14
-rw-r--r--bot/seasons/christmas/__init__.py33
-rw-r--r--bot/seasons/easter/__init__.py35
-rw-r--r--bot/seasons/evergreen/__init__.py17
-rw-r--r--bot/seasons/halloween/__init__.py24
-rw-r--r--bot/seasons/pride/__init__.py36
-rw-r--r--bot/seasons/season.py560
-rw-r--r--bot/seasons/valentines/__init__.py22
-rw-r--r--bot/seasons/wildcard/__init__.py31
-rw-r--r--bot/utils/__init__.py24
-rw-r--r--bot/utils/decorators.py320
-rw-r--r--bot/utils/exceptions.py4
-rw-r--r--bot/utils/pagination.py (renamed from bot/pagination.py)0
-rw-r--r--bot/utils/persist.py4
-rw-r--r--docker-compose.yml1
89 files changed, 1391 insertions, 1122 deletions
diff --git a/bot/__main__.py b/bot/__main__.py
index 2e68a9a4..0ffd6143 100644
--- a/bot/__main__.py
+++ b/bot/__main__.py
@@ -5,7 +5,8 @@ from sentry_sdk.integrations.logging import LoggingIntegration
from bot.bot import bot
from bot.constants import Client, STAFF_ROLES, WHITELISTED_CHANNELS
-from bot.decorators import in_channel_check
+from bot.exts import walk_extensions
+from bot.utils.decorators import in_channel_check
sentry_logging = LoggingIntegration(
level=logging.DEBUG,
@@ -20,6 +21,8 @@ sentry_sdk.init(
log = logging.getLogger(__name__)
bot.add_check(in_channel_check(*WHITELISTED_CHANNELS, bypass_roles=STAFF_ROLES))
-bot.load_extension("bot.help")
-bot.load_extension("bot.seasons")
+
+for ext in walk_extensions():
+ bot.load_extension(ext)
+
bot.run(Client.token)
diff --git a/bot/bot.py b/bot/bot.py
index 8b389b6a..87575fde 100644
--- a/bot/bot.py
+++ b/bot/bot.py
@@ -1,21 +1,43 @@
+import asyncio
+import enum
import logging
import socket
-from traceback import format_exc
-from typing import List
+from typing import Optional, Union
+import async_timeout
+import discord
from aiohttp import AsyncResolver, ClientSession, TCPConnector
-from discord import DiscordException, Embed
+from discord import DiscordException, Embed, Guild, User
from discord.ext import commands
from bot.constants import Channels, Client
+from bot.utils.decorators import mock_in_debug
log = logging.getLogger(__name__)
-__all__ = ('SeasonalBot', 'bot')
+__all__ = ("AssetType", "SeasonalBot", "bot")
+
+
+class AssetType(enum.Enum):
+ """
+ Discord media assets.
+
+ The values match exactly the kwarg keys that can be passed to `Guild.edit` or `User.edit`.
+ """
+
+ BANNER = "banner"
+ AVATAR = "avatar"
+ SERVER_ICON = "icon"
class SeasonalBot(commands.Bot):
- """Base bot instance."""
+ """
+ Base bot instance.
+
+ While in debug mode, the asset upload methods (avatar, banner, ...) will not
+ perform the upload, and will instead only log the passed download urls and pretend
+ that the upload was successful. See the `mock_in_debug` decorator for further details.
+ """
def __init__(self, **kwargs):
super().__init__(**kwargs)
@@ -23,22 +45,106 @@ class SeasonalBot(commands.Bot):
connector=TCPConnector(resolver=AsyncResolver(), family=socket.AF_INET)
)
- def load_extensions(self, exts: List[str]) -> None:
- """Unload all current extensions, then load the given extensions."""
- # 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.
- self.unload_extension(extension)
-
- # Load in the list of cogs that was passed in here
- for extension in exts:
- cog = extension.split(".")[-1]
- try:
- self.load_extension(extension)
- log.info(f'Successfully loaded extension: {cog}')
- except Exception as e:
- log.error(f'Failed to load extension {cog}: {repr(e)} {format_exc()}')
+ @property
+ def member(self) -> Optional[discord.Member]:
+ """Retrieves the guild member object for the bot."""
+ guild = self.get_guild(Client.guild)
+ if not guild:
+ return None
+ return guild.me
+
+ def add_cog(self, cog: commands.Cog) -> None:
+ """
+ Delegate to super to register `cog`.
+
+ This only serves to make the info log, so that extensions don't have to.
+ """
+ super().add_cog(cog)
+ log.info(f"Cog loaded: {cog.qualified_name}")
+
+ async def on_command_error(self, context: commands.Context, exception: DiscordException) -> None:
+ """Check command errors for UserInputError and reset the cooldown if thrown."""
+ if isinstance(exception, commands.UserInputError):
+ context.command.reset_cooldown(context)
+ else:
+ await super().on_command_error(context, exception)
+
+ async def _fetch_image(self, url: str) -> bytes:
+ """Retrieve and read image from `url`."""
+ log.debug(f"Getting image from: {url}")
+ async with self.http_session.get(url) as resp:
+ return await resp.read()
+
+ async def _apply_asset(self, target: Union[Guild, User], asset: AssetType, url: str) -> bool:
+ """
+ Internal method for applying media assets to the guild or the bot.
+
+ This shouldn't be called directly. The purpose of this method is mainly generic
+ error handling to reduce needless code repetition.
+
+ Return True if upload was successful, False otherwise.
+ """
+ log.info(f"Attempting to set {asset.name}: {url}")
+
+ kwargs = {asset.value: await self._fetch_image(url)}
+ try:
+ async with async_timeout.timeout(5):
+ await target.edit(**kwargs)
+
+ except asyncio.TimeoutError:
+ log.info("Asset upload timed out")
+ return False
+
+ except discord.HTTPException as discord_error:
+ log.exception("Asset upload failed", exc_info=discord_error)
+ return False
+
+ else:
+ log.info(f"Asset successfully applied")
+ return True
+
+ @mock_in_debug(return_value=True)
+ async def set_banner(self, url: str) -> bool:
+ """Set the guild's banner to image at `url`."""
+ guild = self.get_guild(Client.guild)
+ if guild is None:
+ log.info("Failed to get guild instance, aborting asset upload")
+ return False
+
+ return await self._apply_asset(guild, AssetType.BANNER, url)
+
+ @mock_in_debug(return_value=True)
+ async def set_icon(self, url: str) -> bool:
+ """Sets the guild's icon to image at `url`."""
+ guild = self.get_guild(Client.guild)
+ if guild is None:
+ log.info("Failed to get guild instance, aborting asset upload")
+ return False
+
+ return await self._apply_asset(guild, AssetType.SERVER_ICON, url)
+
+ @mock_in_debug(return_value=True)
+ async def set_avatar(self, url: str) -> bool:
+ """Set the bot's avatar to image at `url`."""
+ return await self._apply_asset(self.user, AssetType.AVATAR, url)
+
+ @mock_in_debug(return_value=True)
+ async def set_nickname(self, new_name: str) -> bool:
+ """Set the bot nickname in the main guild to `new_name`."""
+ member = self.member
+ if member is None:
+ log.info("Failed to get bot member instance, aborting asset upload")
+ return False
+
+ log.info(f"Attempting to set nickname to {new_name}")
+ try:
+ await member.edit(nick=new_name)
+ except discord.HTTPException as discord_error:
+ log.exception("Setting nickname failed", exc_info=discord_error)
+ return False
+ else:
+ log.info("Nickname set successfully")
+ return True
async def send_log(self, title: str, details: str = None, *, icon: str = None) -> None:
"""Send an embed message to the devlog channel."""
@@ -56,12 +162,5 @@ class SeasonalBot(commands.Bot):
await devlog.send(embed=embed)
- async def on_command_error(self, context: commands.Context, exception: DiscordException) -> None:
- """Check command errors for UserInputError and reset the cooldown if thrown."""
- if isinstance(exception, commands.UserInputError):
- context.command.reset_cooldown(context)
- else:
- await super().on_command_error(context, exception)
-
bot = SeasonalBot(command_prefix=Client.prefix)
diff --git a/bot/constants.py b/bot/constants.py
index e0737e86..ca9bb94a 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -1,22 +1,32 @@
+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",
- "WHITELISTED_CHANNELS", "STAFF_ROLES", "MODERATION_ROLES",
- "POSITIVE_REPLIES", "NEGATIVE_REPLIES", "ERROR_REPLIES",
+ "AdventOfCode",
+ "Branding",
+ "Channels",
+ "Client",
+ "Colours",
+ "Emojis",
+ "Hacktoberfest",
+ "Icons",
+ "Lovefest",
+ "Month",
+ "Roles",
+ "Tokens",
+ "MODERATION_ROLES",
+ "STAFF_ROLES",
+ "WHITELISTED_CHANNELS",
+ "ERROR_REPLIES",
+ "NEGATIVE_REPLIES",
+ "POSITIVE_REPLIES",
)
log = logging.getLogger(__name__)
-bookmark_icon_url = (
- "https://images-ext-2.discordapp.net/external/zl4oDwcmxUILY7sD9ZWE2fU5R7n6QcxEmPYSE5eddbg/"
- "%3Fv%3D1/https/cdn.discordapp.com/emojis/654080405988966419.png?width=20&height=20"
-)
-
class AdventOfCode:
leaderboard_cache_age_threshold_seconds = 3600
@@ -27,6 +37,10 @@ class AdventOfCode:
role_id = int(environ.get("AOC_ROLE_ID", 518565788744024082))
+class Branding:
+ cycle_frequency = int(environ.get("CYCLE_FREQUENCY", 3)) # 0: never, 1: every day, 2: every other day, ...
+
+
class Channels(NamedTuple):
admins = 365960823622991872
advent_of_code = int(environ.get("AOC_CHANNEL_ID", 517745814039166986))
@@ -36,7 +50,6 @@ class Channels(NamedTuple):
checkpoint_test = 422077681434099723
devalerts = 460181980097675264
devlog = int(environ.get("CHANNEL_DEVLOG", 622895325144940554))
- devtest = 414574275865870337
help_0 = 303906576991780866
help_1 = 303906556754395136
help_2 = 303906514266226689
@@ -68,8 +81,8 @@ class Client(NamedTuple):
token = environ.get("SEASONALBOT_TOKEN")
sentry_dsn = environ.get("SEASONALBOT_SENTRY_DSN")
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
+ # Override seasonal locks: 1 (January) to 12 (December)
+ month_override = int(environ["MONTH_OVERRIDE"]) if "MONTH_OVERRIDE" in environ else None
class Colours:
@@ -90,6 +103,7 @@ class Emojis:
check = "\u2611"
envelope = "\U0001F4E8"
trashcan = "<:trashcan:637136429717389331>"
+ ok_hand = ":ok_hand:"
terning1 = "<:terning1:431249668983488527>"
terning2 = "<:terning2:462339216987127808>"
@@ -105,16 +119,44 @@ class Emojis:
merge = "<:PRMerged:629695470570176522>"
+class Hacktoberfest(NamedTuple):
+ voice_id = 514420006474219521
+
+
class Icons:
questionmark = "https://cdn.discordapp.com/emojis/512367613339369475.png"
+ bookmark = (
+ "https://images-ext-2.discordapp.net/external/zl4oDwcmxUILY7sD9ZWE2fU5R7n6QcxEmPYSE5eddbg/"
+ "%3Fv%3D1/https/cdn.discordapp.com/emojis/654080405988966419.png?width=20&height=20"
+ )
class Lovefest:
role_id = int(environ.get("LOVEFEST_ROLE_ID", 542431903886606399))
-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
+
+ def __str__(self) -> str:
+ return self.name.title()
+
+
+# If a month override was configured, check that it's a valid Month
+# Prevents delaying an exception after the bot starts
+if Client.month_override is not None:
+ Month(Client.month_override)
class Roles(NamedTuple):
@@ -141,6 +183,7 @@ class Tokens(NamedTuple):
tmdb = environ.get("TMDB_API_KEY")
nasa = environ.get("NASA_API_KEY")
igdb = environ.get("IGDB_API_KEY")
+ github = environ.get("GITHUB_TOKEN")
# Default role combinations
@@ -149,12 +192,27 @@ STAFF_ROLES = Roles.helpers, Roles.moderator, Roles.admin, Roles.owner
# Whitelisted channels
WHITELISTED_CHANNELS = (
- Channels.bot, Channels.seasonalbot_commands,
- Channels.off_topic_0, Channels.off_topic_1, Channels.off_topic_2,
- Channels.devtest,
+ Channels.bot,
+ Channels.seasonalbot_commands,
+ Channels.off_topic_0,
+ Channels.off_topic_1,
+ Channels.off_topic_2,
)
# Bot replies
+ERROR_REPLIES = [
+ "Please don't do that.",
+ "You have to stop.",
+ "Do you mind?",
+ "In the future, don't do that.",
+ "That was a mistake.",
+ "You blew it.",
+ "You're bad at computers.",
+ "Are you trying to kill me?",
+ "Noooooo!!",
+ "I can't believe you've done this",
+]
+
NEGATIVE_REPLIES = [
"Noooooo!!",
"Nope.",
@@ -194,16 +252,3 @@ POSITIVE_REPLIES = [
"Aye aye, cap'n!",
"I'll allow it.",
]
-
-ERROR_REPLIES = [
- "Please don't do that.",
- "You have to stop.",
- "Do you mind?",
- "In the future, don't do that.",
- "That was a mistake.",
- "You blew it.",
- "You're bad at computers.",
- "Are you trying to kill me?",
- "Noooooo!!",
- "I can't believe you've done this",
-]
diff --git a/bot/decorators.py b/bot/decorators.py
deleted file mode 100644
index 58f67a15..00000000
--- a/bot/decorators.py
+++ /dev/null
@@ -1,177 +0,0 @@
-import logging
-import random
-import typing
-from asyncio import Lock
-from functools import wraps
-from weakref import WeakValueDictionary
-
-from discord import Colour, Embed
-from discord.ext import commands
-from discord.ext.commands import CheckFailure, Context
-
-from bot.constants import ERROR_REPLIES
-
-log = logging.getLogger(__name__)
-
-
-class InChannelCheckFailure(CheckFailure):
- """Check failure when the user runs a command in a non-whitelisted channel."""
-
- pass
-
-
-def with_role(*role_ids: int) -> bool:
- """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
- log.debug(
- f"{ctx.author} tried to use the '{ctx.command.name}'command from a DM. "
- "This command is restricted by the with_role decorator. Rejecting request."
- )
- return False
-
- for role in ctx.author.roles:
- if role.id in role_ids:
- log.debug(f"{ctx.author} has the '{role.name}' role, and passes the check.")
- return True
-
- log.debug(
- f"{ctx.author} does not have the required role to use "
- f"the '{ctx.command.name}' command, so the request is rejected."
- )
- return False
- return commands.check(predicate)
-
-
-def without_role(*role_ids: int) -> bool:
- """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
- log.debug(
- f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM. "
- "This command is restricted by the without_role decorator. Rejecting request."
- )
- return False
-
- author_roles = [role.id for role in ctx.author.roles]
- check = all(role not in author_roles for role in role_ids)
- log.debug(
- f"{ctx.author} tried to call the '{ctx.command.name}' command. "
- f"The result of the without_role check was {check}."
- )
- return check
- return commands.check(predicate)
-
-
-def in_channel_check(*channels: int, bypass_roles: typing.Container[int] = None) -> typing.Callable[[Context], bool]:
- """
- Checks that the message is in a whitelisted channel or optionally has a bypass role.
-
- If `in_channel_override` is present, check if it contains channels
- and use them in place of the global whitelist.
- """
- def predicate(ctx: Context) -> bool:
- if not ctx.guild:
- log.debug(f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM.")
- return True
- if ctx.channel.id in channels:
- log.debug(
- f"{ctx.author} tried to call the '{ctx.command.name}' command "
- f"and the command was used in a whitelisted channel."
- )
- return True
-
- if bypass_roles and any(r.id in bypass_roles for r in ctx.author.roles):
- log.debug(
- f"{ctx.author} called the '{ctx.command.name}' command and "
- f"had a role to bypass the in_channel check."
- )
- return True
-
- if hasattr(ctx.command.callback, "in_channel_override"):
- override = ctx.command.callback.in_channel_override
- if override is None:
- log.debug(
- f"{ctx.author} called the '{ctx.command.name}' command "
- f"and the command was whitelisted to bypass the in_channel check."
- )
- return True
- else:
- if ctx.channel.id in override:
- log.debug(
- f"{ctx.author} tried to call the '{ctx.command.name}' command "
- f"and the command was used in an overridden whitelisted channel."
- )
- return True
-
- log.debug(
- f"{ctx.author} tried to call the '{ctx.command.name}' command. "
- f"The overridden in_channel check failed."
- )
- channels_str = ', '.join(f"<#{c_id}>" for c_id in override)
- raise InChannelCheckFailure(
- f"Sorry, but you may only use this command within {channels_str}."
- )
-
- log.debug(
- f"{ctx.author} tried to call the '{ctx.command.name}' command. "
- f"The in_channel check failed."
- )
-
- channels_str = ', '.join(f"<#{c_id}>" for c_id in channels)
- raise InChannelCheckFailure(
- f"Sorry, but you may only use this command within {channels_str}."
- )
-
- return predicate
-
-
-in_channel = commands.check(in_channel_check)
-
-
-def override_in_channel(channels: typing.Tuple[int] = None) -> typing.Callable:
- """
- Set command callback attribute for detection in `in_channel_check`.
-
- Override global whitelist if channels are specified.
-
- This decorator has to go before (below) below the `command` decorator.
- """
- def inner(func: typing.Callable) -> typing.Callable:
- func.in_channel_override = channels
- return func
-
- return inner
-
-
-def locked() -> typing.Union[typing.Callable, None]:
- """
- Allows the user to only run one instance of the decorated command at a time.
-
- Subsequent calls to the command from the same author are ignored until the command has completed invocation.
-
- This decorator has to go before (below) the `command` decorator.
- """
- def wrap(func: typing.Callable) -> typing.Union[typing.Callable, None]:
- func.__locks = WeakValueDictionary()
-
- @wraps(func)
- async def inner(self: typing.Callable, ctx: Context, *args, **kwargs) -> typing.Union[typing.Callable, None]:
- lock = func.__locks.setdefault(ctx.author.id, Lock())
- if lock.locked():
- embed = Embed()
- embed.colour = Colour.red()
-
- log.debug(f"User tried to invoke a locked command.")
- embed.description = (
- "You're already using this command. Please wait until "
- "it is done before you use it again."
- )
- embed.title = random.choice(ERROR_REPLIES)
- await ctx.send(embed=embed)
- return
-
- async with func.__locks.setdefault(ctx.author.id, Lock()):
- return await func(self, ctx, *args, **kwargs)
- return inner
- return wrap
diff --git a/bot/exts/__init__.py b/bot/exts/__init__.py
new file mode 100644
index 00000000..25deb9af
--- /dev/null
+++ b/bot/exts/__init__.py
@@ -0,0 +1,35 @@
+import logging
+import pkgutil
+from pathlib import Path
+from typing import Iterator
+
+__all__ = ("get_package_names", "walk_extensions")
+
+log = logging.getLogger(__name__)
+
+
+def get_package_names() -> Iterator[str]:
+ """Iterate names of all packages located in /bot/exts/."""
+ for package in pkgutil.iter_modules(__path__):
+ if package.ispkg:
+ yield package.name
+
+
+def walk_extensions() -> Iterator[str]:
+ """
+ Iterate 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.
+
+ This intentionally doesn't make use of pkgutil's `walk_packages`, as we only
+ want to build paths to extensions - not recursively all modules. For some
+ extensions, the `setup` function is in the package's __init__ file, while
+ modules nested under the package are only helpers. Constructing the paths
+ ourselves serves our purpose better.
+ """
+ base_path = Path(__path__[0])
+
+ for package in get_package_names():
+ for extension in pkgutil.iter_modules([base_path.joinpath(package)]):
+ yield f"bot.exts.{package}.{extension.name}"
diff --git a/bot/exts/christmas/__init__.py b/bot/exts/christmas/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bot/exts/christmas/__init__.py
diff --git a/bot/seasons/christmas/adventofcode.py b/bot/exts/christmas/adventofcode.py
index 8caf43bd..cc3923c8 100644
--- a/bot/seasons/christmas/adventofcode.py
+++ b/bot/exts/christmas/adventofcode.py
@@ -13,9 +13,9 @@ from bs4 import BeautifulSoup
from discord.ext import commands
from pytz import timezone
-from bot.constants import AdventOfCode as AocConfig, Channels, Colours, Emojis, Tokens, WHITELISTED_CHANNELS
-from bot.decorators import override_in_channel
+from bot.constants import AdventOfCode as AocConfig, Channels, Colours, Emojis, Month, Tokens, WHITELISTED_CHANNELS
from bot.utils import unlocked_role
+from bot.utils.decorators import in_month, override_in_channel
log = logging.getLogger(__name__)
@@ -153,11 +153,13 @@ class AdventOfCode(commands.Cog):
status_coro = countdown_status(self.bot)
self.status_task = self.bot.loop.create_task(status_coro)
- @commands.group(name="adventofcode", aliases=("aoc",), invoke_without_command=True)
+ @in_month(Month.DECEMBER)
+ @commands.group(name="adventofcode", aliases=("aoc",))
@override_in_channel(AOC_WHITELIST)
async def adventofcode_group(self, ctx: commands.Context) -> None:
"""All of the Advent of Code commands."""
- await ctx.send_help(ctx.command)
+ if not ctx.invoked_subcommand:
+ await ctx.send_help(ctx.command)
@adventofcode_group.command(
name="subscribe",
@@ -739,4 +741,3 @@ def _error_embed_helper(title: str, description: str) -> discord.Embed:
def setup(bot: commands.Bot) -> None:
"""Advent of Code Cog load."""
bot.add_cog(AdventOfCode(bot))
- log.info("AdventOfCode cog loaded")
diff --git a/bot/seasons/christmas/hanukkah_embed.py b/bot/exts/christmas/hanukkah_embed.py
index aaa02b27..4f470a34 100644
--- a/bot/seasons/christmas/hanukkah_embed.py
+++ b/bot/exts/christmas/hanukkah_embed.py
@@ -5,8 +5,8 @@ from typing import List
from discord import Embed
from discord.ext import commands
-from bot.constants import Colours
-
+from bot.constants import Colours, Month
+from bot.utils.decorators import in_month
log = logging.getLogger(__name__)
@@ -34,6 +34,7 @@ class HanukkahEmbed(commands.Cog):
hanukkah_dates.append(date)
return hanukkah_dates
+ @in_month(Month.DECEMBER)
@commands.command(name='hanukkah', aliases=['chanukah'])
async def hanukkah_festival(self, ctx: commands.Context) -> None:
"""Tells you about the Hanukkah Festivaltime of festival, festival day, etc)."""
@@ -110,4 +111,3 @@ class HanukkahEmbed(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Cog load."""
bot.add_cog(HanukkahEmbed(bot))
- log.info("Hanukkah embed cog loaded")
diff --git a/bot/exts/easter/__init__.py b/bot/exts/easter/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bot/exts/easter/__init__.py
diff --git a/bot/seasons/easter/april_fools_vids.py b/bot/exts/easter/april_fools_vids.py
index 4869f510..06108f02 100644
--- a/bot/seasons/easter/april_fools_vids.py
+++ b/bot/exts/easter/april_fools_vids.py
@@ -36,4 +36,3 @@ class AprilFoolVideos(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""April Fools' Cog load."""
bot.add_cog(AprilFoolVideos(bot))
- log.info('April Fools videos cog loaded!')
diff --git a/bot/seasons/easter/avatar_easterifier.py b/bot/exts/easter/avatar_easterifier.py
index e21e35fc..8e8a3500 100644
--- a/bot/seasons/easter/avatar_easterifier.py
+++ b/bot/exts/easter/avatar_easterifier.py
@@ -126,4 +126,3 @@ class AvatarEasterifier(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Avatar Easterifier Cog load."""
bot.add_cog(AvatarEasterifier(bot))
- log.info("AvatarEasterifier cog loaded")
diff --git a/bot/seasons/easter/bunny_name_generator.py b/bot/exts/easter/bunny_name_generator.py
index 97c467e1..3ecf9be9 100644
--- a/bot/seasons/easter/bunny_name_generator.py
+++ b/bot/exts/easter/bunny_name_generator.py
@@ -90,4 +90,3 @@ class BunnyNameGenerator(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Bunny Name Generator Cog load."""
bot.add_cog(BunnyNameGenerator(bot))
- log.info("BunnyNameGenerator cog loaded.")
diff --git a/bot/seasons/easter/conversationstarters.py b/bot/exts/easter/conversationstarters.py
index 3f38ae82..a5f40445 100644
--- a/bot/seasons/easter/conversationstarters.py
+++ b/bot/exts/easter/conversationstarters.py
@@ -26,4 +26,3 @@ class ConvoStarters(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Conversation starters Cog load."""
bot.add_cog(ConvoStarters(bot))
- log.info("ConvoStarters cog loaded")
diff --git a/bot/seasons/easter/easter_riddle.py b/bot/exts/easter/easter_riddle.py
index f5b1aac7..8977534f 100644
--- a/bot/seasons/easter/easter_riddle.py
+++ b/bot/exts/easter/easter_riddle.py
@@ -98,4 +98,3 @@ class EasterRiddle(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Easter Riddle Cog load."""
bot.add_cog(EasterRiddle(bot))
- log.info("Easter Riddle bot loaded")
diff --git a/bot/seasons/easter/egg_decorating.py b/bot/exts/easter/egg_decorating.py
index 23df95f1..be228b2c 100644
--- a/bot/seasons/easter/egg_decorating.py
+++ b/bot/exts/easter/egg_decorating.py
@@ -116,4 +116,3 @@ class EggDecorating(commands.Cog):
def setup(bot: commands.bot) -> None:
"""Egg decorating Cog load."""
bot.add_cog(EggDecorating(bot))
- log.info("EggDecorating cog loaded.")
diff --git a/bot/seasons/easter/egg_facts.py b/bot/exts/easter/egg_facts.py
index e66e25a3..83918fb0 100644
--- a/bot/seasons/easter/egg_facts.py
+++ b/bot/exts/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.utils.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,13 @@ 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."""
+ await self.bot.wait_until_ready()
+
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 +57,4 @@ 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/easter/egghead_quiz.py b/bot/exts/easter/egghead_quiz.py
index bd179fe2..0498d9db 100644
--- a/bot/seasons/easter/egghead_quiz.py
+++ b/bot/exts/easter/egghead_quiz.py
@@ -117,4 +117,3 @@ class EggheadQuiz(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Egghead Quiz Cog load."""
bot.add_cog(EggheadQuiz(bot))
- log.info("EggheadQuiz bot loaded")
diff --git a/bot/seasons/easter/traditions.py b/bot/exts/easter/traditions.py
index 9529823f..85b4adfb 100644
--- a/bot/seasons/easter/traditions.py
+++ b/bot/exts/easter/traditions.py
@@ -28,4 +28,3 @@ class Traditions(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Traditions Cog load."""
bot.add_cog(Traditions(bot))
- log.info("Traditions cog loaded")
diff --git a/bot/seasons/evergreen/8bitify.py b/bot/exts/evergreen/8bitify.py
index 60062fc1..60062fc1 100644
--- a/bot/seasons/evergreen/8bitify.py
+++ b/bot/exts/evergreen/8bitify.py
diff --git a/bot/exts/evergreen/__init__.py b/bot/exts/evergreen/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bot/exts/evergreen/__init__.py
diff --git a/bot/seasons/evergreen/battleship.py b/bot/exts/evergreen/battleship.py
index 9b8aaa48..9bc374e6 100644
--- a/bot/seasons/evergreen/battleship.py
+++ b/bot/exts/evergreen/battleship.py
@@ -441,4 +441,3 @@ class Battleship(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Cog load."""
bot.add_cog(Battleship(bot))
- log.info("Battleship cog loaded")
diff --git a/bot/seasons/evergreen/bookmark.py b/bot/exts/evergreen/bookmark.py
index bd7d5c11..73908702 100644
--- a/bot/seasons/evergreen/bookmark.py
+++ b/bot/exts/evergreen/bookmark.py
@@ -4,7 +4,7 @@ import random
import discord
from discord.ext import commands
-from bot.constants import Colours, ERROR_REPLIES, Emojis, bookmark_icon_url
+from bot.constants import Colours, ERROR_REPLIES, Emojis, Icons
log = logging.getLogger(__name__)
@@ -43,7 +43,7 @@ class Bookmark(commands.Cog):
)
embed.add_field(name="Wanna give it a visit?", value=f"[Visit original message]({target_message.jump_url})")
embed.set_author(name=target_message.author, icon_url=target_message.author.avatar_url)
- embed.set_thumbnail(url=bookmark_icon_url)
+ embed.set_thumbnail(url=Icons.bookmark)
try:
await ctx.author.send(embed=embed)
@@ -62,4 +62,3 @@ class Bookmark(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Load the Bookmark cog."""
bot.add_cog(Bookmark(bot))
- log.info("Bookmark cog loaded")
diff --git a/bot/exts/evergreen/branding.py b/bot/exts/evergreen/branding.py
new file mode 100644
index 00000000..72f31042
--- /dev/null
+++ b/bot/exts/evergreen/branding.py
@@ -0,0 +1,543 @@
+import asyncio
+import itertools
+import json
+import logging
+import random
+import typing as t
+from datetime import datetime, time, timedelta
+from pathlib import Path
+
+import arrow
+import discord
+from discord.embeds import EmptyEmbed
+from discord.ext import commands
+
+from bot.bot import SeasonalBot
+from bot.constants import Branding, Colours, Emojis, MODERATION_ROLES, Tokens
+from bot.seasons import SeasonBase, get_all_seasons, get_current_season, get_season
+from bot.utils import human_months
+from bot.utils.decorators import with_role
+from bot.utils.exceptions import BrandingError
+from bot.utils.persist import make_persistent
+
+log = logging.getLogger(__name__)
+
+STATUS_OK = 200 # HTTP status code
+
+FILE_BANNER = "banner.png"
+FILE_AVATAR = "avatar.png"
+SERVER_ICONS = "server_icons"
+
+BRANDING_URL = "https://api.github.com/repos/python-discord/branding/contents"
+
+PARAMS = {"ref": "master"} # Target branch
+HEADERS = {"Accept": "application/vnd.github.v3+json"} # Ensure we use API v3
+
+# A GitHub token is not necessary for the cog to operate,
+# unauthorized requests are however limited to 60 per hour
+if Tokens.github:
+ HEADERS["Authorization"] = f"token {Tokens.github}"
+
+
+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
+ path: str
+ sha: str
+
+
+def pretty_files(files: t.Iterable[GitHubFile]) -> str:
+ """Provide a human-friendly representation of `files`."""
+ return "\n".join(file.path for file in files)
+
+
+def time_until_midnight() -> timedelta:
+ """
+ Determine amount of time 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
+
+
+class BrandingManager(commands.Cog):
+ """
+ Manages the guild's branding.
+
+ The purpose of this cog is to help automate the synchronization of the branding
+ repository with the guild. It is capable of discovering assets in the repository
+ via GitHub's API, resolving download urls for them, and delegating
+ to the `bot` instance to upload them to the guild.
+
+ BrandingManager is designed to be entirely autonomous. Its `daemon` background task awakens
+ once a day (see `time_until_midnight`) to detect new seasons, or to cycle icons within a single
+ season. The daemon can be turned on and off via the `daemon` cmd group. The value set via
+ its `start` and `stop` commands is persisted across sessions. If turned on, the daemon will
+ automatically start on the next bot start-up. Otherwise, it will wait to be started manually.
+
+ All supported operations, e.g. setting seasons, applying the branding, or cycling icons, can
+ also be invoked manually, via the following API:
+
+ branding list
+ - Show all available seasons
+
+ branding set <season_name>
+ - Set the cog's internal state to represent `season_name`, if it exists
+ - If no `season_name` is given, set chronologically current season
+ - This will not automatically apply the season's branding to the guild,
+ the cog's state can be detached from the guild
+ - Seasons can therefore be 'previewed' using this command
+
+ branding info
+ - View detailed information about resolved assets for current season
+
+ branding refresh
+ - Refresh internal state, i.e. synchronize with branding repository
+
+ branding apply
+ - Apply the current internal state to the guild, i.e. upload the assets
+
+ branding cycle
+ - If there are multiple available icons for current season, randomly pick
+ and apply the next one
+
+ The daemon calls these methods autonomously as appropriate. The use of this cog
+ is locked to moderation roles. As it performs media asset uploads, it is prone to
+ rate-limits - the `apply` command should be used with caution. The `set` command can,
+ however, be used freely to 'preview' seasonal branding and check whether paths have been
+ resolved as appropriate.
+
+ While the bot is in debug mode, it will 'mock' asset uploads by logging the passed
+ download urls and pretending that the upload was successful. Make use of this
+ to test this cog's behaviour.
+ """
+
+ current_season: t.Type[SeasonBase]
+
+ banner: t.Optional[GitHubFile]
+ avatar: t.Optional[GitHubFile]
+
+ available_icons: t.List[GitHubFile]
+ remaining_icons: t.List[GitHubFile]
+
+ days_since_cycle: t.Iterator
+
+ config_file: Path
+
+ daemon: t.Optional[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, or once
+ the `refresh` command is used.
+ """
+ self.bot = bot
+ self.current_season = get_current_season()
+
+ self.banner = None
+ self.avatar = None
+
+ self.available_icons = []
+ self.remaining_icons = []
+
+ self.days_since_cycle = itertools.cycle([None])
+
+ self.config_file = make_persistent(Path("bot", "resources", "evergreen", "branding.json"))
+ should_run = self._read_config()["daemon_active"]
+
+ if should_run:
+ self.daemon = self.bot.loop.create_task(self._daemon_func())
+ else:
+ self.daemon = None
+
+ @property
+ def _daemon_running(self) -> bool:
+ """True if the daemon is currently active, False otherwise."""
+ return self.daemon is not None and not self.daemon.done()
+
+ def _read_config(self) -> t.Dict[str, bool]:
+ """Read and return persistent config file."""
+ with self.config_file.open("r") as persistent_file:
+ return json.load(persistent_file)
+
+ def _write_config(self, key: str, value: bool) -> None:
+ """Write a `key`, `value` pair to persistent config file."""
+ current_config = self._read_config()
+ current_config[key] = value
+
+ with self.config_file.open("w") as persistent_file:
+ json.dump(current_config, persistent_file)
+
+ 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 internal loop runs once when activated, then periodically at the time
+ given by `time_until_midnight`.
+
+ All method calls in the internal loop are considered safe, i.e. no errors propagate
+ to the daemon's loop. The daemon itself does not perform any error handling on its own.
+ """
+ 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.days_since_cycle) == Branding.cycle_frequency:
+ await self.cycle()
+
+ until_midnight = time_until_midnight()
+ await asyncio.sleep(until_midnight.total_seconds())
+
+ async def _info_embed(self) -> discord.Embed:
+ """Make an informative embed representing current season."""
+ info_embed = discord.Embed(description=self.current_season.description, colour=self.current_season.colour)
+
+ # If we're in a non-evergreen season, also show active months
+ if self.current_season is not SeasonBase:
+ title = f"{self.current_season.season_name} ({human_months(self.current_season.months)})"
+ else:
+ title = self.current_season.season_name
+
+ # Use the author field to show the season's name and avatar if available
+ info_embed.set_author(name=title, icon_url=self.avatar.download_url if self.avatar else EmptyEmbed)
+
+ banner = self.banner.path if self.banner is not None else "Unavailable"
+ info_embed.add_field(name="Banner", value=banner, inline=False)
+
+ avatar = self.avatar.path if self.avatar is not None else "Unavailable"
+ info_embed.add_field(name="Avatar", value=avatar, inline=False)
+
+ icons = pretty_files(self.available_icons) or "Unavailable"
+ info_embed.add_field(name="Available icons", value=icons, inline=False)
+
+ # Only display cycle frequency if we're actually cycling
+ if len(self.available_icons) > 1 and Branding.cycle_frequency:
+ info_embed.set_footer(text=f"Icon cycle frequency: {Branding.cycle_frequency}")
+
+ 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_days_since_cycle(self) -> None:
+ """
+ Reset the `days_since_cycle` iterator based on configured frequency.
+
+ If the current season only has 1 icon, or if `Branding.cycle_frequency` is falsey,
+ the iterator will always yield None. This signals that the icon shouldn't be cycled.
+
+ Otherwise, it will yield ints in range [1, `Branding.cycle_frequency`] indefinitely.
+ When the iterator yields a value equal to `Branding.cycle_frequency`, it is time to cycle.
+ """
+ if len(self.available_icons) > 1 and Branding.cycle_frequency:
+ sequence = range(1, Branding.cycle_frequency + 1)
+ else:
+ sequence = [None]
+
+ self.days_since_cycle = itertools.cycle(sequence)
+
+ async def _get_files(self, path: str, include_dirs: bool = False) -> t.Dict[str, GitHubFile]:
+ """
+ Get files at `path` in the branding repository.
+
+ If `include_dirs` is False (default), only returns files at `path`.
+ Otherwise, will return both files and directories. Never returns symlinks.
+
+ Return dict mapping from filename to corresponding `GitHubFile` instance.
+ This may return an empty dict if the response status is non-200,
+ or if the target directory is empty.
+ """
+ url = f"{BRANDING_URL}/{path}"
+ async with self.bot.http_session.get(url, headers=HEADERS, params=PARAMS) as resp:
+ # Short-circuit if we get non-200 response
+ if resp.status != STATUS_OK:
+ log.error(f"GitHub API returned non-200 response: {resp}")
+ return {}
+ directory = await resp.json() # Directory at `path`
+
+ allowed_types = {"file", "dir"} if include_dirs else {"file"}
+ return {
+ file["name"]: GitHubFile(file["download_url"], file["path"], file["sha"])
+ for file in directory
+ if file["type"] in allowed_types
+ }
+
+ async def refresh(self) -> bool:
+ """
+ Synchronize available assets with branding repository.
+
+ If the current season is not the evergreen, and lacks at least one asset,
+ we use the evergreen seasonal dir as fallback for missing assets.
+
+ Finally, if neither the seasonal nor fallback branding directories contain
+ an asset, it will simply be ignored.
+
+ 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, include_dirs=True)
+
+ # Only make a call to the fallback directory if there is something to be gained
+ branding_incomplete = any(
+ asset not in seasonal_dir
+ for asset in (FILE_BANNER, FILE_AVATAR, SERVER_ICONS)
+ )
+ if branding_incomplete and self.current_season is not SeasonBase:
+ fallback_dir = await self._get_files(SeasonBase.branding_path, include_dirs=True)
+ else:
+ fallback_dir = {}
+
+ # Resolve assets in this directory, None is a safe value
+ self.banner = seasonal_dir.get(FILE_BANNER) or fallback_dir.get(FILE_BANNER)
+ self.avatar = seasonal_dir.get(FILE_AVATAR) or fallback_dir.get(FILE_AVATAR)
+
+ # Now resolve server icons by making a call to the proper sub-directory
+ 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())
+
+ elif SERVER_ICONS in fallback_dir:
+ icons_dir = await self._get_files(f"{SeasonBase.branding_path}/{SERVER_ICONS}")
+ self.available_icons = list(icons_dir.values())
+
+ else:
+ self.available_icons = [] # This should never be the case, but an empty list is a safe value
+
+ # GitHubFile instances carry a `sha` attr so this will pick up if a file changes
+ branding_changed = old_branding != (self.banner, self.avatar, self.available_icons)
+
+ if branding_changed:
+ log.info(f"New branding detected (season: {self.current_season.season_name})")
+ await self._reset_remaining_icons()
+ await self._reset_days_since_cycle()
+
+ return branding_changed
+
+ async def cycle(self) -> bool:
+ """
+ Apply the next-up server icon.
+
+ Returns True if an icon is available and successfully gets applied, False otherwise.
+ """
+ if not self.available_icons:
+ log.info("Cannot cycle: no icons for this season")
+ return False
+
+ if not self.remaining_icons:
+ log.info("Reset & shuffle remaining icons")
+ await self._reset_remaining_icons()
+
+ next_up = self.remaining_icons.pop(0)
+ success = await self.bot.set_icon(next_up.download_url)
+
+ return success
+
+ async def apply(self) -> t.List[str]:
+ """
+ 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.
+
+ Returns a list of names of all failed assets. An asset is considered failed
+ if it isn't found in the branding repo, or if something goes wrong while the
+ bot is trying to apply it.
+
+ An empty list denotes that all assets have been applied successfully.
+ """
+ report = {asset: False for asset in ("banner", "avatar", "nickname", "icon")}
+
+ if self.banner is not None:
+ report["banner"] = await self.bot.set_banner(self.banner.download_url)
+
+ if self.avatar is not None:
+ report["avatar"] = await self.bot.set_avatar(self.avatar.download_url)
+
+ if self.current_season.bot_name:
+ report["nickname"] = await self.bot.set_nickname(self.current_season.bot_name)
+
+ report["icon"] = await self.cycle()
+
+ failed_assets = [asset for asset, succeeded in report.items() if not succeeded]
+ return failed_assets
+
+ @with_role(*MODERATION_ROLES)
+ @commands.group(name="branding")
+ async def branding_cmds(self, ctx: commands.Context) -> None:
+ """Manual branding control."""
+ if not ctx.invoked_subcommand:
+ await ctx.send_help(ctx.command)
+
+ @branding_cmds.command(name="list", aliases=["ls"])
+ async def branding_list(self, ctx: commands.Context) -> None:
+ """List all available seasons and branding sources."""
+ embed = discord.Embed(title="Available seasons", colour=Colours.soft_green)
+
+ for season in get_all_seasons():
+ if season is SeasonBase:
+ active_when = "always"
+ else:
+ active_when = f"in {human_months(season.months)}"
+
+ description = (
+ f"Active {active_when}\n"
+ f"Branding: {season.branding_path}"
+ )
+ embed.add_field(name=season.season_name, value=description, inline=False)
+
+ await ctx.send(embed=embed)
+
+ @branding_cmds.command(name="set")
+ async def branding_set(self, ctx: commands.Context, *, season_name: t.Optional[str] = None) -> None:
+ """
+ Manually set season, or reset to current if none given.
+
+ Season search is a case-less comparison against both seasonal class name,
+ and its `season_name` attr.
+
+ This only pre-loads the cog's internal state to the chosen season, but does not
+ automatically apply the branding. As that is an expensive operation, the `apply`
+ command must be called explicitly after this command finishes.
+
+ This means that this command can be used to 'preview' a season gathering info
+ about its available assets, without applying them to the guild.
+
+ If the daemon is running, it will automatically reset the season to current when
+ it wakes up. The season set via this command can therefore remain 'detached' from
+ what it should be - the daemon will make sure that it's set back properly.
+ """
+ if season_name is None:
+ new_season = get_current_season()
+ else:
+ new_season = get_season(season_name)
+ if new_season is None:
+ raise BrandingError("No such season exists")
+
+ if self.current_season is new_season:
+ raise BrandingError(f"Season {self.current_season.season_name} already active")
+
+ self.current_season = new_season
+ await self.branding_refresh(ctx)
+
+ @branding_cmds.command(name="info", aliases=["status"])
+ async def branding_info(self, ctx: commands.Context) -> None:
+ """
+ Show available assets for current season.
+
+ This can be used to confirm that assets have been resolved properly.
+ When `apply` is used, it attempts to upload exactly the assets listed here.
+ """
+ await ctx.send(embed=await self._info_embed())
+
+ @branding_cmds.command(name="refresh")
+ async def branding_refresh(self, ctx: commands.Context) -> None:
+ """Sync currently available assets with branding repository."""
+ async with ctx.typing():
+ await self.refresh()
+ await self.branding_info(ctx)
+
+ @branding_cmds.command(name="apply")
+ async def branding_apply(self, ctx: commands.Context) -> None:
+ """
+ Apply current season's branding to the guild.
+
+ Use `info` to check which assets will be applied. Shows which assets have
+ failed to be applied, if any.
+ """
+ async with ctx.typing():
+ failed_assets = await self.apply()
+ if failed_assets:
+ raise BrandingError(f"Failed to apply following assets: {', '.join(failed_assets)}")
+
+ response = discord.Embed(description=f"All assets applied {Emojis.ok_hand}", colour=Colours.soft_green)
+ await ctx.send(embed=response)
+
+ @branding_cmds.command(name="cycle")
+ async def branding_cycle(self, ctx: commands.Context) -> None:
+ """
+ Apply the next-up guild icon, if multiple are available.
+
+ The order is random.
+ """
+ async with ctx.typing():
+ success = await self.cycle()
+ if not success:
+ raise BrandingError("Failed to cycle icon")
+
+ response = discord.Embed(description=f"Success {Emojis.ok_hand}", colour=Colours.soft_green)
+ await ctx.send(embed=response)
+
+ @branding_cmds.group(name="daemon", aliases=["d", "task"])
+ async def daemon_group(self, ctx: commands.Context) -> None:
+ """Control the background daemon."""
+ if not ctx.invoked_subcommand:
+ await ctx.send_help(ctx.command)
+
+ @daemon_group.command(name="status")
+ async def daemon_status(self, ctx: commands.Context) -> None:
+ """Check whether daemon is currently active."""
+ if self._daemon_running:
+ remaining_time = (arrow.utcnow() + time_until_midnight()).humanize()
+ response = discord.Embed(description=f"Daemon running {Emojis.ok_hand}", colour=Colours.soft_green)
+ response.set_footer(text=f"Next refresh {remaining_time}")
+ else:
+ response = discord.Embed(description="Daemon not running", colour=Colours.soft_red)
+
+ await ctx.send(embed=response)
+
+ @daemon_group.command(name="start")
+ async def daemon_start(self, ctx: commands.Context) -> None:
+ """If the daemon isn't running, start it."""
+ if self._daemon_running:
+ raise BrandingError("Daemon already running!")
+
+ self.daemon = self.bot.loop.create_task(self._daemon_func())
+ self._write_config("daemon_active", True)
+
+ response = discord.Embed(description=f"Daemon started {Emojis.ok_hand}", colour=Colours.soft_green)
+ await ctx.send(embed=response)
+
+ @daemon_group.command(name="stop")
+ async def daemon_stop(self, ctx: commands.Context) -> None:
+ """If the daemon is running, stop it."""
+ if not self._daemon_running:
+ raise BrandingError("Daemon not running!")
+
+ self.daemon.cancel()
+ self._write_config("daemon_active", False)
+
+ response = discord.Embed(description=f"Daemon stopped {Emojis.ok_hand}", colour=Colours.soft_green)
+ await ctx.send(embed=response)
+
+
+def setup(bot: SeasonalBot) -> None:
+ """Load BrandingManager cog."""
+ bot.add_cog(BrandingManager(bot))
diff --git a/bot/seasons/evergreen/error_handler.py b/bot/exts/evergreen/error_handler.py
index 2753a6df..33b1a3f2 100644
--- a/bot/seasons/evergreen/error_handler.py
+++ b/bot/exts/evergreen/error_handler.py
@@ -8,7 +8,8 @@ from discord.ext import commands
from sentry_sdk import push_scope
from bot.constants import Colours, ERROR_REPLIES, NEGATIVE_REPLIES
-from bot.decorators import InChannelCheckFailure
+from bot.utils.decorators import InChannelCheckFailure, InMonthCheckFailure
+from bot.utils.exceptions import BrandingError
log = logging.getLogger(__name__)
@@ -56,7 +57,11 @@ class CommandErrorHandler(commands.Cog):
if isinstance(error, commands.CommandNotFound):
return
- if isinstance(error, InChannelCheckFailure):
+ if isinstance(error, BrandingError):
+ await ctx.send(embed=self.error_embed(str(error)))
+ return
+
+ if isinstance(error, (InChannelCheckFailure, InMonthCheckFailure)):
await ctx.send(embed=self.error_embed(str(error), NEGATIVE_REPLIES), delete_after=7.5)
return
@@ -122,4 +127,3 @@ class CommandErrorHandler(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Error handler Cog load."""
bot.add_cog(CommandErrorHandler(bot))
- log.info("CommandErrorHandler cog loaded")
diff --git a/bot/seasons/evergreen/fun.py b/bot/exts/evergreen/fun.py
index 889ae079..67a4bae5 100644
--- a/bot/seasons/evergreen/fun.py
+++ b/bot/exts/evergreen/fun.py
@@ -145,4 +145,3 @@ class Fun(Cog):
def setup(bot: commands.Bot) -> None:
"""Fun Cog load."""
bot.add_cog(Fun(bot))
- log.info("Fun cog loaded")
diff --git a/bot/seasons/evergreen/game.py b/bot/exts/evergreen/game.py
index 1a0c3e89..3c8b2725 100644
--- a/bot/seasons/evergreen/game.py
+++ b/bot/exts/evergreen/game.py
@@ -13,8 +13,8 @@ from discord.ext.commands import Cog, Context, group
from bot.bot import SeasonalBot
from bot.constants import STAFF_ROLES, Tokens
-from bot.decorators import with_role
-from bot.pagination import ImagePaginator, LinePaginator
+from bot.utils.decorators import with_role
+from bot.utils.pagination import ImagePaginator, LinePaginator
# Base URL of IGDB API
BASE_URL = "https://api-v3.igdb.com"
diff --git a/bot/help.py b/bot/exts/evergreen/help.py
index 7209b71e..ccd76d76 100644
--- a/bot/help.py
+++ b/bot/exts/evergreen/help.py
@@ -1,6 +1,7 @@
# Help command from Python bot. All commands that will be added to there in futures should be added to here too.
import asyncio
import itertools
+import logging
from collections import namedtuple
from contextlib import suppress
from typing import Union
@@ -13,7 +14,7 @@ from fuzzywuzzy import fuzz, process
from bot import constants
from bot.bot import SeasonalBot
from bot.constants import Emojis
-from bot.pagination import (
+from bot.utils.pagination import (
FIRST_EMOJI, LAST_EMOJI,
LEFT_EMOJI, LinePaginator, RIGHT_EMOJI,
)
@@ -30,6 +31,8 @@ REACTIONS = {
Cog = namedtuple('Cog', ['name', 'description', 'commands'])
+log = logging.getLogger(__name__)
+
class HelpQueryNotFound(ValueError):
"""
diff --git a/bot/seasons/evergreen/issues.py b/bot/exts/evergreen/issues.py
index fba5b174..4129156a 100644
--- a/bot/seasons/evergreen/issues.py
+++ b/bot/exts/evergreen/issues.py
@@ -4,7 +4,7 @@ import discord
from discord.ext import commands
from bot.constants import Colours, Emojis, WHITELISTED_CHANNELS
-from bot.decorators import override_in_channel
+from bot.utils.decorators import override_in_channel
log = logging.getLogger(__name__)
@@ -74,4 +74,3 @@ class Issues(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Cog Retrieves Issues From Github."""
bot.add_cog(Issues(bot))
- log.info("Issues cog loaded")
diff --git a/bot/seasons/evergreen/magic_8ball.py b/bot/exts/evergreen/magic_8ball.py
index e47ef454..c10f1f51 100644
--- a/bot/seasons/evergreen/magic_8ball.py
+++ b/bot/exts/evergreen/magic_8ball.py
@@ -29,4 +29,3 @@ class Magic8ball(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Magic 8ball Cog load."""
bot.add_cog(Magic8ball(bot))
- log.info("Magic8ball cog loaded")
diff --git a/bot/seasons/evergreen/minesweeper.py b/bot/exts/evergreen/minesweeper.py
index b0ba8145..b59cdb14 100644
--- a/bot/seasons/evergreen/minesweeper.py
+++ b/bot/exts/evergreen/minesweeper.py
@@ -282,4 +282,3 @@ class Minesweeper(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Load the Minesweeper cog."""
bot.add_cog(Minesweeper(bot))
- log.info("Minesweeper cog loaded")
diff --git a/bot/seasons/evergreen/movie.py b/bot/exts/evergreen/movie.py
index 3c5a312d..93aeef30 100644
--- a/bot/seasons/evergreen/movie.py
+++ b/bot/exts/evergreen/movie.py
@@ -9,7 +9,7 @@ from discord import Embed
from discord.ext.commands import Bot, Cog, Context, group
from bot.constants import Tokens
-from bot.pagination import ImagePaginator
+from bot.utils.pagination import ImagePaginator
# Define base URL of TMDB
BASE_URL = "https://api.themoviedb.org/3/"
diff --git a/bot/seasons/evergreen/recommend_game.py b/bot/exts/evergreen/recommend_game.py
index 835a4e53..7cd52c2c 100644
--- a/bot/seasons/evergreen/recommend_game.py
+++ b/bot/exts/evergreen/recommend_game.py
@@ -48,4 +48,3 @@ class RecommendGame(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Loads the RecommendGame cog."""
bot.add_cog(RecommendGame(bot))
- log.info("RecommendGame cog loaded")
diff --git a/bot/seasons/evergreen/reddit.py b/bot/exts/evergreen/reddit.py
index 32ca419a..fe204419 100644
--- a/bot/seasons/evergreen/reddit.py
+++ b/bot/exts/evergreen/reddit.py
@@ -5,8 +5,7 @@ import discord
from discord.ext import commands
from discord.ext.commands.cooldowns import BucketType
-from bot.pagination import ImagePaginator
-
+from bot.utils.pagination import ImagePaginator
log = logging.getLogger(__name__)
@@ -127,4 +126,3 @@ class Reddit(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Load the Cog."""
bot.add_cog(Reddit(bot))
- log.debug('Loaded')
diff --git a/bot/seasons/evergreen/showprojects.py b/bot/exts/evergreen/showprojects.py
index a943e548..328a7aa5 100644
--- a/bot/seasons/evergreen/showprojects.py
+++ b/bot/exts/evergreen/showprojects.py
@@ -31,4 +31,3 @@ class ShowProjects(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Show Projects Reaction Cog."""
bot.add_cog(ShowProjects(bot))
- log.info("ShowProjects cog loaded")
diff --git a/bot/seasons/evergreen/snakes/__init__.py b/bot/exts/evergreen/snakes/__init__.py
index d7f9f20c..2eae2751 100644
--- a/bot/seasons/evergreen/snakes/__init__.py
+++ b/bot/exts/evergreen/snakes/__init__.py
@@ -2,7 +2,7 @@ import logging
from discord.ext import commands
-from bot.seasons.evergreen.snakes.snakes_cog import Snakes
+from bot.exts.evergreen.snakes.snakes_cog import Snakes
log = logging.getLogger(__name__)
@@ -10,4 +10,3 @@ log = logging.getLogger(__name__)
def setup(bot: commands.Bot) -> None:
"""Snakes Cog load."""
bot.add_cog(Snakes(bot))
- log.info("Snakes cog loaded")
diff --git a/bot/seasons/evergreen/snakes/converter.py b/bot/exts/evergreen/snakes/converter.py
index 57103b57..d4e93b56 100644
--- a/bot/seasons/evergreen/snakes/converter.py
+++ b/bot/exts/evergreen/snakes/converter.py
@@ -7,7 +7,7 @@ import discord
from discord.ext.commands import Context, Converter
from fuzzywuzzy import fuzz
-from bot.seasons.evergreen.snakes.utils import SNAKE_RESOURCES
+from bot.exts.evergreen.snakes.utils import SNAKE_RESOURCES
from bot.utils import disambiguate
log = logging.getLogger(__name__)
diff --git a/bot/seasons/evergreen/snakes/snakes_cog.py b/bot/exts/evergreen/snakes/snakes_cog.py
index 09f5e250..36c176ce 100644
--- a/bot/seasons/evergreen/snakes/snakes_cog.py
+++ b/bot/exts/evergreen/snakes/snakes_cog.py
@@ -18,9 +18,9 @@ from discord import Colour, Embed, File, Member, Message, Reaction
from discord.ext.commands import BadArgument, Bot, Cog, CommandError, Context, bot_has_permissions, group
from bot.constants import ERROR_REPLIES, Tokens
-from bot.decorators import locked
-from bot.seasons.evergreen.snakes import utils
-from bot.seasons.evergreen.snakes.converter import Snake
+from bot.exts.evergreen.snakes import utils
+from bot.exts.evergreen.snakes.converter import Snake
+from bot.utils.decorators import locked
log = logging.getLogger(__name__)
diff --git a/bot/seasons/evergreen/snakes/utils.py b/bot/exts/evergreen/snakes/utils.py
index 7d6caf04..7d6caf04 100644
--- a/bot/seasons/evergreen/snakes/utils.py
+++ b/bot/exts/evergreen/snakes/utils.py
diff --git a/bot/seasons/evergreen/space.py b/bot/exts/evergreen/space.py
index 89b31e87..89b31e87 100644
--- a/bot/seasons/evergreen/space.py
+++ b/bot/exts/evergreen/space.py
diff --git a/bot/seasons/evergreen/speedrun.py b/bot/exts/evergreen/speedrun.py
index 76c5e8d3..4e8d7aee 100644
--- a/bot/seasons/evergreen/speedrun.py
+++ b/bot/exts/evergreen/speedrun.py
@@ -25,4 +25,3 @@ class Speedrun(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Load the Speedrun cog."""
bot.add_cog(Speedrun(bot))
- log.info("Speedrun cog loaded")
diff --git a/bot/seasons/evergreen/trivia_quiz.py b/bot/exts/evergreen/trivia_quiz.py
index 99b64497..c1a271e8 100644
--- a/bot/seasons/evergreen/trivia_quiz.py
+++ b/bot/exts/evergreen/trivia_quiz.py
@@ -300,4 +300,3 @@ class TriviaQuiz(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Load the cog."""
bot.add_cog(TriviaQuiz(bot))
- logger.debug("TriviaQuiz cog loaded")
diff --git a/bot/seasons/evergreen/uptime.py b/bot/exts/evergreen/uptime.py
index 6f24f545..a9ad9dfb 100644
--- a/bot/seasons/evergreen/uptime.py
+++ b/bot/exts/evergreen/uptime.py
@@ -31,4 +31,3 @@ class Uptime(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Uptime Cog load."""
bot.add_cog(Uptime(bot))
- log.info("Uptime cog loaded")
diff --git a/bot/seasons/halloween/8ball.py b/bot/exts/halloween/8ball.py
index 2e1c2804..1df48fbf 100644
--- a/bot/seasons/halloween/8ball.py
+++ b/bot/exts/halloween/8ball.py
@@ -31,4 +31,3 @@ class SpookyEightBall(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Spooky Eight Ball Cog Load."""
bot.add_cog(SpookyEightBall(bot))
- log.info("SpookyEightBall cog loaded")
diff --git a/bot/exts/halloween/__init__.py b/bot/exts/halloween/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bot/exts/halloween/__init__.py
diff --git a/bot/seasons/halloween/candy_collection.py b/bot/exts/halloween/candy_collection.py
index 490609dd..90c29eb2 100644
--- a/bot/seasons/halloween/candy_collection.py
+++ b/bot/exts/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.utils.decorators import in_month
log = logging.getLogger(__name__)
@@ -34,6 +35,7 @@ class CandyCollection(commands.Cog):
userid = userinfo['userid']
self.get_candyinfo[userid] = userinfo
+ @in_month(Month.OCTOBER)
@commands.Cog.listener()
async def on_message(self, message: discord.Message) -> None:
"""Randomly adds candy or skull reaction to non-bot messages in the Event channel."""
@@ -55,6 +57,7 @@ class CandyCollection(commands.Cog):
self.msg_reacted.append(d)
return await message.add_reaction('\N{CANDY}')
+ @in_month(Month.OCTOBER)
@commands.Cog.listener()
async def on_reaction_add(self, reaction: discord.Reaction, user: discord.Member) -> None:
"""Add/remove candies from a person if the reaction satisfies criteria."""
@@ -178,6 +181,7 @@ class CandyCollection(commands.Cog):
with open(json_location, 'w') as outfile:
json.dump(self.candy_json, outfile)
+ @in_month(Month.OCTOBER)
@commands.command()
async def candy(self, ctx: commands.Context) -> None:
"""Get the candy leaderboard and save to JSON."""
@@ -218,4 +222,3 @@ class CandyCollection(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Candy Collection game Cog load."""
bot.add_cog(CandyCollection(bot))
- log.info("CandyCollection cog loaded")
diff --git a/bot/seasons/halloween/hacktober-issue-finder.py b/bot/exts/halloween/hacktober-issue-finder.py
index 10732374..b5ad1c4f 100644
--- a/bot/seasons/halloween/hacktober-issue-finder.py
+++ b/bot/exts/halloween/hacktober-issue-finder.py
@@ -7,6 +7,9 @@ import aiohttp
import discord
from discord.ext import commands
+from bot.constants import Month
+from bot.utils.decorators import in_month
+
log = logging.getLogger(__name__)
URL = "https://api.github.com/search/issues?per_page=100&q=is:issue+label:hacktoberfest+language:python+state:open"
@@ -23,6 +26,7 @@ class HacktoberIssues(commands.Cog):
self.cache_beginner = None
self.cache_timer_beginner = datetime.datetime(1, 1, 1)
+ @in_month(Month.OCTOBER)
@commands.command()
async def hacktoberissues(self, ctx: commands.Context, option: str = "") -> None:
"""
@@ -104,4 +108,3 @@ class HacktoberIssues(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Hacktober issue finder Cog Load."""
bot.add_cog(HacktoberIssues(bot))
- log.info("hacktober-issue-finder cog loaded")
diff --git a/bot/seasons/halloween/hacktoberstats.py b/bot/exts/halloween/hacktoberstats.py
index d61e048b..e01ee50c 100644
--- a/bot/seasons/halloween/hacktoberstats.py
+++ b/bot/exts/halloween/hacktoberstats.py
@@ -10,11 +10,10 @@ import aiohttp
import discord
from discord.ext import commands
-from bot.constants import Channels, WHITELISTED_CHANNELS
-from bot.decorators import override_in_channel
+from bot.constants import Channels, Month, WHITELISTED_CHANNELS
+from bot.utils.decorators import in_month, override_in_channel
from bot.utils.persist import make_persistent
-
log = logging.getLogger(__name__)
CURRENT_YEAR = datetime.now().year # Used to construct GH API query
@@ -30,6 +29,7 @@ class HacktoberStats(commands.Cog):
self.link_json = make_persistent(Path("bot", "resources", "halloween", "github_links.json"))
self.linked_accounts = self.load_linked_users()
+ @in_month(Month.OCTOBER)
@commands.group(name="hacktoberstats", aliases=("hackstats",), invoke_without_command=True)
@override_in_channel(HACKTOBER_WHITELIST)
async def hacktoberstats_group(self, ctx: commands.Context, github_username: str = None) -> None:
@@ -57,6 +57,7 @@ class HacktoberStats(commands.Cog):
await self.get_stats(ctx, github_username)
+ @in_month(Month.OCTOBER)
@hacktoberstats_group.command(name="link")
@override_in_channel(HACKTOBER_WHITELIST)
async def link_user(self, ctx: commands.Context, github_username: str = None) -> None:
@@ -91,6 +92,7 @@ class HacktoberStats(commands.Cog):
logging.info(f"{author_id} tried to link a GitHub account but didn't provide a username")
await ctx.send(f"{author_mention}, a GitHub username is required to link your account")
+ @in_month(Month.OCTOBER)
@hacktoberstats_group.command(name="unlink")
@override_in_channel(HACKTOBER_WHITELIST)
async def unlink_user(self, ctx: commands.Context) -> None:
@@ -337,4 +339,3 @@ class HacktoberStats(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Hacktoberstats Cog load."""
bot.add_cog(HacktoberStats(bot))
- log.info("HacktoberStats cog loaded")
diff --git a/bot/seasons/halloween/halloween_facts.py b/bot/exts/halloween/halloween_facts.py
index 94730d9e..44a66ab2 100644
--- a/bot/seasons/halloween/halloween_facts.py
+++ b/bot/exts/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)
@@ -65,4 +56,3 @@ class HalloweenFacts(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Halloween facts Cog load."""
bot.add_cog(HalloweenFacts(bot))
- log.info("HalloweenFacts cog loaded")
diff --git a/bot/seasons/halloween/halloweenify.py b/bot/exts/halloween/halloweenify.py
index dfcc2b1e..5c433a81 100644
--- a/bot/seasons/halloween/halloweenify.py
+++ b/bot/exts/halloween/halloweenify.py
@@ -49,4 +49,3 @@ class Halloweenify(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Halloweenify Cog load."""
bot.add_cog(Halloweenify(bot))
- log.info("Halloweenify cog loaded")
diff --git a/bot/seasons/halloween/monsterbio.py b/bot/exts/halloween/monsterbio.py
index bfa8a026..016a66d1 100644
--- a/bot/seasons/halloween/monsterbio.py
+++ b/bot/exts/halloween/monsterbio.py
@@ -53,4 +53,3 @@ class MonsterBio(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Monster bio Cog load."""
bot.add_cog(MonsterBio(bot))
- log.info("MonsterBio cog loaded.")
diff --git a/bot/seasons/halloween/monstersurvey.py b/bot/exts/halloween/monstersurvey.py
index 12e1d022..27da79b6 100644
--- a/bot/seasons/halloween/monstersurvey.py
+++ b/bot/exts/halloween/monstersurvey.py
@@ -203,4 +203,3 @@ class MonsterSurvey(Cog):
def setup(bot: Bot) -> None:
"""Monster survey Cog load."""
bot.add_cog(MonsterSurvey(bot))
- log.info("MonsterSurvey cog loaded")
diff --git a/bot/seasons/halloween/scarymovie.py b/bot/exts/halloween/scarymovie.py
index 3823a3e4..c80e0298 100644
--- a/bot/seasons/halloween/scarymovie.py
+++ b/bot/exts/halloween/scarymovie.py
@@ -129,4 +129,3 @@ class ScaryMovie(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Scary movie Cog load."""
bot.add_cog(ScaryMovie(bot))
- log.info("ScaryMovie cog loaded")
diff --git a/bot/seasons/halloween/spookyavatar.py b/bot/exts/halloween/spookyavatar.py
index 268de3fb..2d7df678 100644
--- a/bot/seasons/halloween/spookyavatar.py
+++ b/bot/exts/halloween/spookyavatar.py
@@ -50,4 +50,3 @@ class SpookyAvatar(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Spooky avatar Cog load."""
bot.add_cog(SpookyAvatar(bot))
- log.info("SpookyAvatar cog loaded")
diff --git a/bot/seasons/halloween/spookygif.py b/bot/exts/halloween/spookygif.py
index 818de8cd..f402437f 100644
--- a/bot/seasons/halloween/spookygif.py
+++ b/bot/exts/halloween/spookygif.py
@@ -36,4 +36,3 @@ class SpookyGif(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Spooky GIF Cog load."""
bot.add_cog(SpookyGif(bot))
- log.info("SpookyGif cog loaded")
diff --git a/bot/seasons/halloween/spookyrating.py b/bot/exts/halloween/spookyrating.py
index 7f78f536..1a48194e 100644
--- a/bot/seasons/halloween/spookyrating.py
+++ b/bot/exts/halloween/spookyrating.py
@@ -64,4 +64,3 @@ class SpookyRating(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Spooky Rating Cog load."""
bot.add_cog(SpookyRating(bot))
- log.info("SpookyRating cog loaded")
diff --git a/bot/seasons/halloween/spookyreact.py b/bot/exts/halloween/spookyreact.py
index 90b1254d..e5945aea 100644
--- a/bot/seasons/halloween/spookyreact.py
+++ b/bot/exts/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.utils.decorators import in_month
+
log = logging.getLogger(__name__)
SPOOKY_TRIGGERS = {
@@ -23,6 +26,7 @@ class SpookyReact(Cog):
def __init__(self, bot: Bot):
self.bot = bot
+ @in_month(Month.OCTOBER)
@Cog.listener()
async def on_message(self, ctx: discord.Message) -> None:
"""
@@ -69,4 +73,3 @@ class SpookyReact(Cog):
def setup(bot: Bot) -> None:
"""Spooky reaction Cog load."""
bot.add_cog(SpookyReact(bot))
- log.info("SpookyReact cog loaded")
diff --git a/bot/seasons/halloween/spookysound.py b/bot/exts/halloween/spookysound.py
index e0676d0a..325447e5 100644
--- a/bot/seasons/halloween/spookysound.py
+++ b/bot/exts/halloween/spookysound.py
@@ -45,4 +45,3 @@ class SpookySound(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Spooky sound Cog load."""
bot.add_cog(SpookySound(bot))
- log.info("SpookySound cog loaded")
diff --git a/bot/seasons/halloween/timeleft.py b/bot/exts/halloween/timeleft.py
index 8cb3f4f6..295acc89 100644
--- a/bot/seasons/halloween/timeleft.py
+++ b/bot/exts/halloween/timeleft.py
@@ -57,4 +57,3 @@ class TimeLeft(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Cog load."""
bot.add_cog(TimeLeft(bot))
- log.info("TimeLeft cog loaded")
diff --git a/bot/exts/pride/__init__.py b/bot/exts/pride/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bot/exts/pride/__init__.py
diff --git a/bot/seasons/pride/drag_queen_name.py b/bot/exts/pride/drag_queen_name.py
index 43813fbd..95297745 100644
--- a/bot/seasons/pride/drag_queen_name.py
+++ b/bot/exts/pride/drag_queen_name.py
@@ -30,4 +30,3 @@ class DragNames(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Cog loader for drag queen name generator."""
bot.add_cog(DragNames(bot))
- log.info("Drag queen name generator cog loaded!")
diff --git a/bot/seasons/pride/pride_anthem.py b/bot/exts/pride/pride_anthem.py
index b0c6d34e..186c5fff 100644
--- a/bot/seasons/pride/pride_anthem.py
+++ b/bot/exts/pride/pride_anthem.py
@@ -55,4 +55,3 @@ class PrideAnthem(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Cog loader for pride anthem."""
bot.add_cog(PrideAnthem(bot))
- log.info("Pride anthems cog loaded!")
diff --git a/bot/seasons/pride/pride_avatar.py b/bot/exts/pride/pride_avatar.py
index 85e49d5c..3f9878e3 100644
--- a/bot/seasons/pride/pride_avatar.py
+++ b/bot/exts/pride/pride_avatar.py
@@ -142,4 +142,3 @@ class PrideAvatar(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Cog load."""
bot.add_cog(PrideAvatar(bot))
- log.info("PrideAvatar cog loaded")
diff --git a/bot/seasons/pride/pride_facts.py b/bot/exts/pride/pride_facts.py
index 5c19dfd0..f759dcb1 100644
--- a/bot/seasons/pride/pride_facts.py
+++ b/bot/exts/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.utils.decorators import seasonal_task
log = logging.getLogger(__name__)
@@ -25,18 +24,21 @@ 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."""
+ await self.bot.wait_until_ready()
+
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 +103,4 @@ 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/exts/valentines/__init__.py b/bot/exts/valentines/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bot/exts/valentines/__init__.py
diff --git a/bot/seasons/valentines/be_my_valentine.py b/bot/exts/valentines/be_my_valentine.py
index ab8ea290..e5e71d25 100644
--- a/bot/seasons/valentines/be_my_valentine.py
+++ b/bot/exts/valentines/be_my_valentine.py
@@ -8,7 +8,8 @@ import discord
from discord.ext import commands
from discord.ext.commands.cooldowns import BucketType
-from bot.constants import Channels, Client, Colours, Lovefest
+from bot.constants import Channels, Client, Colours, Lovefest, Month
+from bot.utils.decorators import in_month
log = logging.getLogger(__name__)
@@ -30,7 +31,8 @@ class BeMyValentine(commands.Cog):
valentines = load(json_data)
return valentines
- @commands.group(name="lovefest", invoke_without_command=True)
+ @in_month(Month.FEBRUARY)
+ @commands.group(name="lovefest")
async def lovefest_role(self, ctx: commands.Context) -> None:
"""
Subscribe or unsubscribe from the lovefest role.
@@ -40,7 +42,8 @@ class BeMyValentine(commands.Cog):
1) use the command \".lovefest sub\" to get the lovefest role.
2) use the command \".lovefest unsub\" to get rid of the lovefest role.
"""
- await ctx.send_help(ctx.command)
+ if not ctx.invoked_subcommand:
+ await ctx.send_help(ctx.command)
@lovefest_role.command(name="sub")
async def add_role(self, ctx: commands.Context) -> None:
@@ -231,4 +234,3 @@ class BeMyValentine(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Be my Valentine Cog load."""
bot.add_cog(BeMyValentine(bot))
- log.info("BeMyValentine cog loaded")
diff --git a/bot/seasons/valentines/lovecalculator.py b/bot/exts/valentines/lovecalculator.py
index 03d3d7d5..e11e062b 100644
--- a/bot/seasons/valentines/lovecalculator.py
+++ b/bot/exts/valentines/lovecalculator.py
@@ -101,4 +101,3 @@ class LoveCalculator(Cog):
def setup(bot: commands.Bot) -> None:
"""Love calculator Cog load."""
bot.add_cog(LoveCalculator(bot))
- log.info("LoveCalculator cog loaded")
diff --git a/bot/seasons/valentines/movie_generator.py b/bot/exts/valentines/movie_generator.py
index ce1d7d5b..0843175a 100644
--- a/bot/seasons/valentines/movie_generator.py
+++ b/bot/exts/valentines/movie_generator.py
@@ -60,4 +60,3 @@ class RomanceMovieFinder(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Romance movie Cog load."""
bot.add_cog(RomanceMovieFinder(bot))
- log.info("RomanceMovieFinder cog loaded")
diff --git a/bot/seasons/valentines/myvalenstate.py b/bot/exts/valentines/myvalenstate.py
index 0256c39a..7d8737c4 100644
--- a/bot/seasons/valentines/myvalenstate.py
+++ b/bot/exts/valentines/myvalenstate.py
@@ -84,4 +84,3 @@ class MyValenstate(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Valenstate Cog load."""
bot.add_cog(MyValenstate(bot))
- log.info("MyValenstate cog loaded")
diff --git a/bot/seasons/valentines/pickuplines.py b/bot/exts/valentines/pickuplines.py
index 8b2c9822..74c7e68b 100644
--- a/bot/seasons/valentines/pickuplines.py
+++ b/bot/exts/valentines/pickuplines.py
@@ -42,4 +42,3 @@ class PickupLine(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Pickup lines Cog load."""
bot.add_cog(PickupLine(bot))
- log.info('PickupLine cog loaded')
diff --git a/bot/seasons/valentines/savethedate.py b/bot/exts/valentines/savethedate.py
index e0bc3904..ac38d279 100644
--- a/bot/seasons/valentines/savethedate.py
+++ b/bot/exts/valentines/savethedate.py
@@ -39,4 +39,3 @@ class SaveTheDate(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Save the date Cog Load."""
bot.add_cog(SaveTheDate(bot))
- log.info("SaveTheDate cog loaded")
diff --git a/bot/seasons/valentines/valentine_zodiac.py b/bot/exts/valentines/valentine_zodiac.py
index c8d77e75..1a1273aa 100644
--- a/bot/seasons/valentines/valentine_zodiac.py
+++ b/bot/exts/valentines/valentine_zodiac.py
@@ -55,4 +55,3 @@ class ValentineZodiac(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Valentine zodiac Cog load."""
bot.add_cog(ValentineZodiac(bot))
- log.info("ValentineZodiac cog loaded")
diff --git a/bot/seasons/valentines/whoisvalentine.py b/bot/exts/valentines/whoisvalentine.py
index b8586dca..4ca0289c 100644
--- a/bot/seasons/valentines/whoisvalentine.py
+++ b/bot/exts/valentines/whoisvalentine.py
@@ -50,4 +50,3 @@ class ValentineFacts(commands.Cog):
def setup(bot: commands.Bot) -> None:
"""Who is Valentine Cog load."""
bot.add_cog(ValentineFacts(bot))
- log.info("ValentineFacts cog loaded")
diff --git a/bot/resources/evergreen/branding.json b/bot/resources/evergreen/branding.json
new file mode 100644
index 00000000..747c0fe8
--- /dev/null
+++ b/bot/resources/evergreen/branding.json
@@ -0,0 +1,3 @@
+{
+ "daemon_active": false
+}
diff --git a/bot/seasons.py b/bot/seasons.py
new file mode 100644
index 00000000..55cfef3c
--- /dev/null
+++ b/bot/seasons.py
@@ -0,0 +1,181 @@
+import logging
+import typing as t
+
+from bot.constants import Colours, Month
+from bot.utils import resolve_current_month
+from bot.utils.exceptions import BrandingError
+
+log = logging.getLogger(__name__)
+
+
+class SeasonBase:
+ """
+ Base for Seasonal classes.
+
+ This serves as the off-season fallback for when no specific
+ seasons are active.
+
+ Seasons are 'registered' simply by inheriting from `SeasonBase`.
+ We discover them by calling `__subclasses__`.
+ """
+
+ season_name: str = "Evergreen"
+ bot_name: str = "SeasonalBot"
+
+ colour: str = Colours.soft_green
+ description: str = "The default season!"
+
+ branding_path: str = "seasonal/evergreen"
+
+ months: t.Set[Month] = set(Month)
+
+
+class Christmas(SeasonBase):
+ """Branding for December."""
+
+ season_name = "Festive season"
+ bot_name = "MerryBot"
+
+ colour = Colours.soft_red
+ description = (
+ "The time is here to get into the festive spirit! No matter who you are, where you are, "
+ "or what beliefs you may follow, we hope every one of you enjoy this festive season!"
+ )
+
+ branding_path = "seasonal/christmas"
+
+ months = {Month.DECEMBER}
+
+
+class Easter(SeasonBase):
+ """Branding for April."""
+
+ season_name = "Easter"
+ bot_name = "BunnyBot"
+
+ colour = Colours.bright_green
+ description = (
+ "Bunny here, bunny there, bunny everywhere! Here at Python Discord, we celebrate "
+ "our version of Easter during the entire month of April."
+ )
+
+ branding_path = "seasonal/easter"
+
+ months = {Month.APRIL}
+
+
+class Halloween(SeasonBase):
+ """Branding for October."""
+
+ season_name = "Halloween"
+ bot_name = "NeonBot"
+
+ colour = Colours.orange
+ description = "Trick or treat?!"
+
+ branding_path = "seasonal/halloween"
+
+ months = {Month.OCTOBER}
+
+
+class Pride(SeasonBase):
+ """Branding for June."""
+
+ season_name = "Pride"
+ bot_name = "ProudBot"
+
+ colour = Colours.pink
+ description = (
+ "The month of June is a special month for us at Python Discord. It is very important to us "
+ "that everyone feels welcome here, no matter their origin, identity or sexuality. During the "
+ "month of June, while some of you are participating in Pride festivals across the world, "
+ "we will be celebrating individuality and commemorating the history and challenges "
+ "of the LGBTQ+ community with a Pride event of our own!"
+ )
+
+ branding_path = "seasonal/pride"
+
+ months = {Month.JUNE}
+
+
+class Valentines(SeasonBase):
+ """Branding for February."""
+
+ season_name = "Valentines"
+ bot_name = "TenderBot"
+
+ colour = Colours.pink
+ description = "Love is in the air!"
+
+ branding_path = "seasonal/valentines"
+
+ months = {Month.FEBRUARY}
+
+
+class Wildcard(SeasonBase):
+ """Branding for August."""
+
+ season_name = "Wildcard"
+ bot_name = "RetroBot"
+
+ colour = Colours.purple
+ description = "A season full of surprises!"
+
+ months = {Month.AUGUST}
+
+
+def get_all_seasons() -> t.List[t.Type[SeasonBase]]:
+ """Give all available season classes."""
+ return [SeasonBase] + SeasonBase.__subclasses__()
+
+
+def get_current_season() -> t.Type[SeasonBase]:
+ """Give active season, based on current UTC month."""
+ current_month = resolve_current_month()
+
+ active_seasons = tuple(
+ season
+ for season in SeasonBase.__subclasses__()
+ if current_month in season.months
+ )
+
+ if not active_seasons:
+ return SeasonBase
+
+ return active_seasons[0]
+
+
+def get_season(name: str) -> t.Optional[t.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 get_all_seasons():
+ matches = (season.__name__.casefold(), season.season_name.casefold())
+
+ if name in matches:
+ return season
+
+
+def _validate_season_overlap() -> None:
+ """
+ Raise BrandingError if there are any colliding seasons.
+
+ This serves as a local test to ensure that seasons haven't been misconfigured.
+ """
+ month_to_season = {}
+
+ for season in SeasonBase.__subclasses__():
+ for month in season.months:
+ colliding_season = month_to_season.get(month)
+
+ if colliding_season:
+ raise BrandingError(f"Season {season} collides with {colliding_season} in {month.name}")
+ else:
+ month_to_season[month] = season
+
+
+_validate_season_overlap()
diff --git a/bot/seasons/__init__.py b/bot/seasons/__init__.py
deleted file mode 100644
index 7faf9164..00000000
--- a/bot/seasons/__init__.py
+++ /dev/null
@@ -1,14 +0,0 @@
-import logging
-
-from discord.ext import commands
-
-from bot.seasons.season import SeasonBase, SeasonManager, get_season
-
-__all__ = ("SeasonBase", "get_season")
-
-log = logging.getLogger(__name__)
-
-
-def setup(bot: commands.Bot) -> None:
- bot.add_cog(SeasonManager(bot))
- log.info("SeasonManager cog loaded")
diff --git a/bot/seasons/christmas/__init__.py b/bot/seasons/christmas/__init__.py
deleted file mode 100644
index 4287efb7..00000000
--- a/bot/seasons/christmas/__init__.py
+++ /dev/null
@@ -1,33 +0,0 @@
-import datetime
-
-from bot.constants import Colours
-from bot.seasons import SeasonBase
-
-
-class Christmas(SeasonBase):
- """
- Christmas seasonal event attributes.
-
- We are getting into the festive spirit with a new server icon, new bot name and avatar, and some
- new commands for you to check out!
-
- No matter who you are, where you are or what beliefs you may follow, we hope every one of you
- enjoy this festive season!
- """
-
- name = "christmas"
- 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",
- )
-
- @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)
diff --git a/bot/seasons/easter/__init__.py b/bot/seasons/easter/__init__.py
deleted file mode 100644
index dd60bf5c..00000000
--- a/bot/seasons/easter/__init__.py
+++ /dev/null
@@ -1,35 +0,0 @@
-from bot.constants import Colours
-from bot.seasons import SeasonBase
-
-
-class Easter(SeasonBase):
- """
- Here at Python Discord, we celebrate our version of Easter during the entire month of April.
-
- While this celebration takes place, you'll notice a few changes:
-
- • The server icon has changed to our Easter icon. Thanks to <@140605665772175361> for the
- design!
-
- • [Easter issues now available for SeasonalBot on the repo](https://git.io/fjkvQ).
-
- • You may see stuff like an Easter themed esoteric challenge, a celebration of Earth Day, or
- Easter-related micro-events for you to join. Stay tuned!
-
- If you'd like to contribute, head on over to <#635950537262759947> and we will help you get
- started. It doesn't matter if you're new to open source or Python, if you'd like to help, we
- will find you a task and teach you what you need to know.
- """
-
- name = "easter"
- bot_name = "BunnyBot"
- greeting = "Happy Easter!"
-
- # Duration of season
- start_date = "02/04"
- end_date = "30/04"
-
- colour = Colours.pink
- icon = (
- "/logos/logo_seasonal/easter/easter.png",
- )
diff --git a/bot/seasons/evergreen/__init__.py b/bot/seasons/evergreen/__init__.py
deleted file mode 100644
index b3d0dc63..00000000
--- a/bot/seasons/evergreen/__init__.py
+++ /dev/null
@@ -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/halloween/__init__.py b/bot/seasons/halloween/__init__.py
deleted file mode 100644
index c81879d7..00000000
--- a/bot/seasons/halloween/__init__.py
+++ /dev/null
@@ -1,24 +0,0 @@
-from bot.constants import Colours
-from bot.seasons import SeasonBase
-
-
-class Halloween(SeasonBase):
- """
- Halloween Seasonal event attributes.
-
- Announcement for this cog temporarily disabled, since we're doing a custom
- Hacktoberfest announcement. If you're enabling the announcement again,
- make sure to update this docstring accordingly.
- """
-
- name = "halloween"
- bot_name = "NeonBot"
- greeting = "Happy Halloween!"
-
- start_date = "01/10"
- end_date = "01/11"
-
- colour = Colours.pink
- icon = (
- "/logos/logo_seasonal/hacktober/hacktoberfest.png",
- )
diff --git a/bot/seasons/pride/__init__.py b/bot/seasons/pride/__init__.py
deleted file mode 100644
index 08df2fa1..00000000
--- a/bot/seasons/pride/__init__.py
+++ /dev/null
@@ -1,36 +0,0 @@
-from bot.constants import Colours
-from bot.seasons import SeasonBase
-
-
-class Pride(SeasonBase):
- """
- The month of June is a special month for us at Python Discord.
-
- It is very important to us that everyone feels welcome here, no matter their origin,
- identity or sexuality. During the month of June, while some of you are participating in Pride
- festivals across the world, we will be celebrating individuality and commemorating the history
- and challenges of the LGBTQ+ community with a Pride event of our own!
-
- While this celebration takes place, you'll notice a few changes:
- • The server icon has changed to our Pride icon. Thanks to <@98694745760481280> for the design!
- • [Pride issues are now available for SeasonalBot on the repo](https://git.io/pythonpride).
- • You may see Pride-themed esoteric challenges and other microevents.
-
- If you'd like to contribute, head on over to <#635950537262759947> and we will help you get
- started. It doesn't matter if you're new to open source or Python, if you'd like to help, we
- will find you a task and teach you what you need to know.
- """
-
- name = "pride"
- bot_name = "ProudBot"
- greeting = "Happy Pride Month!"
-
- # Duration of season
- start_date = "01/06"
- end_date = "01/07"
-
- # Season logo
- colour = Colours.soft_red
- icon = (
- "/logos/logo_seasonal/pride/logo_pride.png",
- )
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
deleted file mode 100644
index 6e5d16f7..00000000
--- a/bot/seasons/valentines/__init__.py
+++ /dev/null
@@ -1,22 +0,0 @@
-from bot.constants import Colours
-from bot.seasons import SeasonBase
-
-
-class Valentines(SeasonBase):
- """
- Love is in the air! We've got a new icon and set of commands for the season of love.
-
- Get yourself into the bot-commands channel and check out the new features!
- """
-
- name = "valentines"
- bot_name = "Tenderbot"
- greeting = "Get loved-up!"
-
- start_date = "01/02"
- end_date = "01/03"
-
- colour = Colours.pink
- icon = (
- "/logos/logo_seasonal/valentines/loved_up.png",
- )
diff --git a/bot/seasons/wildcard/__init__.py b/bot/seasons/wildcard/__init__.py
deleted file mode 100644
index 354e979d..00000000
--- a/bot/seasons/wildcard/__init__.py
+++ /dev/null
@@ -1,31 +0,0 @@
-from bot.seasons import SeasonBase
-
-
-class Wildcard(SeasonBase):
- """
- For the month of August, the season is a Wildcard.
-
- This docstring will not be used for announcements.
- Instead, we'll do the announcement manually, since
- it will change every year.
-
- This class needs slight changes every year,
- such as the bot_name, bot_icon and icon.
-
- IMPORTANT: DO NOT ADD ANY FEATURES TO THIS FOLDER.
- ALL WILDCARD FEATURES SHOULD BE ADDED
- TO THE EVERGREEN FOLDER!
- """
-
- 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",
- )
diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py
index 25fd4b96..35ef0a7b 100644
--- a/bot/utils/__init__.py
+++ b/bot/utils/__init__.py
@@ -2,12 +2,32 @@ import asyncio
import contextlib
import re
import string
-from typing import List
+from datetime import datetime
+from typing import Iterable, List
import discord
from discord.ext.commands import BadArgument, Context
-from bot.pagination import LinePaginator
+from bot.constants import Client, Month
+from bot.utils.pagination import LinePaginator
+
+
+def human_months(months: Iterable[Month]) -> str:
+ """Build a comma separated list of `months`."""
+ return ", ".join(str(m) for m in months)
+
+
+def resolve_current_month() -> Month:
+ """
+ Determine current month w.r.t. `Client.month_override` env var.
+
+ If the env variable was set, current month always resolves to the configured value.
+ Otherwise, the current UTC month is given.
+ """
+ if Client.month_override is not None:
+ return Month(Client.month_override)
+ else:
+ return Month(datetime.utcnow().month)
async def disambiguate(
diff --git a/bot/utils/decorators.py b/bot/utils/decorators.py
new file mode 100644
index 00000000..519e61a9
--- /dev/null
+++ b/bot/utils/decorators.py
@@ -0,0 +1,320 @@
+import asyncio
+import functools
+import logging
+import random
+import typing as t
+from asyncio import Lock
+from functools import wraps
+from weakref import WeakValueDictionary
+
+from discord import Colour, Embed
+from discord.ext import commands
+from discord.ext.commands import CheckFailure, Command, Context
+
+from bot.constants import Client, ERROR_REPLIES, Month
+from bot.utils import human_months, resolve_current_month
+
+ONE_DAY = 24 * 60 * 60
+
+log = logging.getLogger(__name__)
+
+
+class InChannelCheckFailure(CheckFailure):
+ """Check failure when the user runs a command in a non-whitelisted channel."""
+
+ pass
+
+
+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: t.Union[float, int] = ONE_DAY) -> t.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.
+
+ The wrapped task is responsible for waiting for the bot to be ready, if necessary.
+ """
+ def decorator(task_body: t.Callable) -> t.Callable:
+ @functools.wraps(task_body)
+ async def decorated_task(*args, **kwargs) -> None:
+ """Call `task_body` once every `sleep_time` seconds in `allowed_months`."""
+ log.info(f"Starting seasonal task {task_body.__qualname__} ({human_months(allowed_months)})")
+
+ while True:
+ current_month = resolve_current_month()
+
+ if current_month in allowed_months:
+ await task_body(*args, **kwargs)
+ else:
+ log.debug(f"Seasonal task {task_body.__qualname__} sleeps in {current_month!s}")
+
+ await asyncio.sleep(sleep_time)
+ return decorated_task
+ return decorator
+
+
+def in_month_listener(*allowed_months: Month) -> t.Callable:
+ """
+ Shield a listener from being invoked outside of `allowed_months`.
+
+ The check is performed against current UTC month.
+ """
+ def decorator(listener: t.Callable) -> t.Callable:
+ @functools.wraps(listener)
+ async def guarded_listener(*args, **kwargs) -> None:
+ """Wrapped listener will abort if not in allowed month."""
+ current_month = resolve_current_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!s}")
+ return guarded_listener
+ return decorator
+
+
+def in_month_command(*allowed_months: Month) -> t.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 = resolve_current_month()
+ can_run = current_month in allowed_months
+
+ log.debug(
+ f"Command '{ctx.command}' is locked to months {human_months(allowed_months)}. "
+ f"Invoking it in month {current_month!s} is {'allowed' if can_run else 'disallowed'}."
+ )
+ if can_run:
+ return True
+ else:
+ raise InMonthCheckFailure(f"Command can only be used in {human_months(allowed_months)}")
+
+ return commands.check(predicate)
+
+
+def in_month(*allowed_months: Month) -> t.Callable:
+ """
+ Universal decorator for season-locking commands and listeners alike.
+
+ This only serves to determine whether the decorated callable is a command,
+ a listener, or neither. It then delegates to either `in_month_command`,
+ or `in_month_listener`, or raises TypeError, respectively.
+
+ Please note that in order for this decorator to correctly determine whether
+ the decorated callable is a cmd or listener, it **has** to first be turned
+ into one. This means that this decorator should always be placed **above**
+ the d.py one that registers it as either.
+
+ This will decorate groups as well, as those subclass Command. In order to lock
+ all subcommands of a group, its `invoke_without_command` param must **not** be
+ manually set to True - this causes a circumvention of the group's callback
+ and the seasonal check applied to it.
+ """
+ def decorator(callable_: t.Callable) -> t.Callable:
+ # Functions decorated as commands are turned into instances of `Command`
+ if isinstance(callable_, Command):
+ logging.debug(f"Command {callable_.qualified_name} will be locked to {human_months(allowed_months)}")
+ actual_deco = in_month_command(*allowed_months)
+
+ # D.py will assign this attribute when `callable_` is registered as a listener
+ elif hasattr(callable_, "__cog_listener__"):
+ logging.debug(f"Listener {callable_.__qualname__} will be locked to {human_months(allowed_months)}")
+ actual_deco = in_month_listener(*allowed_months)
+
+ # Otherwise we're unsure exactly what has been decorated
+ # This happens before the bot starts, so let's just raise
+ else:
+ raise TypeError(f"Decorated object {callable_} is neither a command nor a listener")
+
+ return actual_deco(callable_)
+ return decorator
+
+
+def with_role(*role_ids: int) -> t.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
+ log.debug(
+ f"{ctx.author} tried to use the '{ctx.command.name}'command from a DM. "
+ "This command is restricted by the with_role decorator. Rejecting request."
+ )
+ return False
+
+ for role in ctx.author.roles:
+ if role.id in role_ids:
+ log.debug(f"{ctx.author} has the '{role.name}' role, and passes the check.")
+ return True
+
+ log.debug(
+ f"{ctx.author} does not have the required role to use "
+ f"the '{ctx.command.name}' command, so the request is rejected."
+ )
+ return False
+ return commands.check(predicate)
+
+
+def without_role(*role_ids: int) -> t.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
+ log.debug(
+ f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM. "
+ "This command is restricted by the without_role decorator. Rejecting request."
+ )
+ return False
+
+ author_roles = [role.id for role in ctx.author.roles]
+ check = all(role not in author_roles for role in role_ids)
+ log.debug(
+ f"{ctx.author} tried to call the '{ctx.command.name}' command. "
+ f"The result of the without_role check was {check}."
+ )
+ return check
+ return commands.check(predicate)
+
+
+def in_channel_check(*channels: int, bypass_roles: t.Container[int] = None) -> t.Callable[[Context], bool]:
+ """
+ Checks that the message is in a whitelisted channel or optionally has a bypass role.
+
+ If `in_channel_override` is present, check if it contains channels
+ and use them in place of the global whitelist.
+ """
+ def predicate(ctx: Context) -> bool:
+ if not ctx.guild:
+ log.debug(f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM.")
+ return True
+ if ctx.channel.id in channels:
+ log.debug(
+ f"{ctx.author} tried to call the '{ctx.command.name}' command "
+ f"and the command was used in a whitelisted channel."
+ )
+ return True
+
+ if bypass_roles and any(r.id in bypass_roles for r in ctx.author.roles):
+ log.debug(
+ f"{ctx.author} called the '{ctx.command.name}' command and "
+ f"had a role to bypass the in_channel check."
+ )
+ return True
+
+ if hasattr(ctx.command.callback, "in_channel_override"):
+ override = ctx.command.callback.in_channel_override
+ if override is None:
+ log.debug(
+ f"{ctx.author} called the '{ctx.command.name}' command "
+ f"and the command was whitelisted to bypass the in_channel check."
+ )
+ return True
+ else:
+ if ctx.channel.id in override:
+ log.debug(
+ f"{ctx.author} tried to call the '{ctx.command.name}' command "
+ f"and the command was used in an overridden whitelisted channel."
+ )
+ return True
+
+ log.debug(
+ f"{ctx.author} tried to call the '{ctx.command.name}' command. "
+ f"The overridden in_channel check failed."
+ )
+ channels_str = ', '.join(f"<#{c_id}>" for c_id in override)
+ raise InChannelCheckFailure(
+ f"Sorry, but you may only use this command within {channels_str}."
+ )
+
+ log.debug(
+ f"{ctx.author} tried to call the '{ctx.command.name}' command. "
+ f"The in_channel check failed."
+ )
+
+ channels_str = ', '.join(f"<#{c_id}>" for c_id in channels)
+ raise InChannelCheckFailure(
+ f"Sorry, but you may only use this command within {channels_str}."
+ )
+
+ return predicate
+
+
+in_channel = commands.check(in_channel_check)
+
+
+def override_in_channel(channels: t.Tuple[int] = None) -> t.Callable:
+ """
+ Set command callback attribute for detection in `in_channel_check`.
+
+ Override global whitelist if channels are specified.
+
+ This decorator has to go before (below) below the `command` decorator.
+ """
+ def inner(func: t.Callable) -> t.Callable:
+ func.in_channel_override = channels
+ return func
+
+ return inner
+
+
+def locked() -> t.Union[t.Callable, None]:
+ """
+ Allows the user to only run one instance of the decorated command at a time.
+
+ Subsequent calls to the command from the same author are ignored until the command has completed invocation.
+
+ This decorator has to go before (below) the `command` decorator.
+ """
+ def wrap(func: t.Callable) -> t.Union[t.Callable, None]:
+ func.__locks = WeakValueDictionary()
+
+ @wraps(func)
+ async def inner(self: t.Callable, ctx: Context, *args, **kwargs) -> t.Union[t.Callable, None]:
+ lock = func.__locks.setdefault(ctx.author.id, Lock())
+ if lock.locked():
+ embed = Embed()
+ embed.colour = Colour.red()
+
+ log.debug(f"User tried to invoke a locked command.")
+ embed.description = (
+ "You're already using this command. Please wait until "
+ "it is done before you use it again."
+ )
+ embed.title = random.choice(ERROR_REPLIES)
+ await ctx.send(embed=embed)
+ return
+
+ async with func.__locks.setdefault(ctx.author.id, Lock()):
+ return await func(self, ctx, *args, **kwargs)
+ return inner
+ return wrap
+
+
+def mock_in_debug(return_value: t.Any) -> t.Callable:
+ """
+ Short-circuit function execution if in debug mode and return `return_value`.
+
+ The original function name, and the incoming args and kwargs are DEBUG level logged
+ upon each call. This is useful for expensive operations, i.e. media asset uploads
+ that are prone to rate-limits but need to be tested extensively.
+ """
+ def decorator(func: t.Callable) -> t.Callable:
+ @functools.wraps(func)
+ async def wrapped(*args, **kwargs) -> t.Any:
+ """Short-circuit and log if in debug mode."""
+ if Client.debug:
+ log.debug(f"Function {func.__name__} called with args: {args}, kwargs: {kwargs}")
+ return return_value
+ return await func(*args, **kwargs)
+ return wrapped
+ return decorator
diff --git a/bot/utils/exceptions.py b/bot/utils/exceptions.py
new file mode 100644
index 00000000..70c20e12
--- /dev/null
+++ b/bot/utils/exceptions.py
@@ -0,0 +1,4 @@
+class BrandingError(Exception):
+ """Exception raised by the BrandingManager cog."""
+
+ pass
diff --git a/bot/pagination.py b/bot/utils/pagination.py
index 9a7a0382..9a7a0382 100644
--- a/bot/pagination.py
+++ b/bot/utils/pagination.py
diff --git a/bot/utils/persist.py b/bot/utils/persist.py
index a60a1219..d78e5420 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.exts import get_package_names
DIRECTORY = Path("data") # directory that has a persistent volume mapped to it
@@ -41,7 +41,7 @@ def make_persistent(file_path: Path) -> Path:
raise OSError(f"File not found at {file_path}.")
# detect season in datafile path for assigning to subdirectory
- season = next((s for s in get_seasons() if s in file_path.parts), None)
+ season = next((s for s in get_package_names() if s in file_path.parts), None)
if season:
# make sure subdirectory exists first
diff --git a/docker-compose.yml b/docker-compose.yml
index 30e8a109..6cf5e9bd 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -16,7 +16,6 @@ services:
# - SEASONALBOT_ADMIN_ROLE_ID=
# - CHANNEL_ANNOUNCEMENTS=
# - CHANNEL_DEVLOG=
- # - SEASON_OVERRIDE=
volumes:
- /opt/pythondiscord/seasonalbot/log:/bot/bot/log