diff options
author | 2020-04-02 14:39:24 +0200 | |
---|---|---|
committer | 2020-04-02 14:39:24 +0200 | |
commit | d77a2bbc50305d05371197f4cfe3354cfca4c627 (patch) | |
tree | be1eed54972d9843f66114311f93b68b579046ac | |
parent | Merge pull request #382 from ks129/game-fuzzy (diff) | |
parent | Merge master: adjust `Space` cog location (diff) |
Merge pull request #329 from python-discord/seasonal-purge
Deseasonify: Make all cogs available year-round, and manage only branding by season.
-rw-r--r-- | bot/__main__.py | 9 | ||||
-rw-r--r-- | bot/bot.py | 155 | ||||
-rw-r--r-- | bot/constants.py | 107 | ||||
-rw-r--r-- | bot/decorators.py | 177 | ||||
-rw-r--r-- | bot/exts/__init__.py | 35 | ||||
-rw-r--r-- | bot/exts/christmas/__init__.py | 0 | ||||
-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__.py | 0 | ||||
-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__.py | 0 | ||||
-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.py | 543 | ||||
-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__.py | 0 | ||||
-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__.py | 0 | ||||
-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__.py | 0 | ||||
-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.json | 3 | ||||
-rw-r--r-- | bot/seasons.py | 181 | ||||
-rw-r--r-- | bot/seasons/__init__.py | 14 | ||||
-rw-r--r-- | bot/seasons/christmas/__init__.py | 33 | ||||
-rw-r--r-- | bot/seasons/easter/__init__.py | 35 | ||||
-rw-r--r-- | bot/seasons/evergreen/__init__.py | 17 | ||||
-rw-r--r-- | bot/seasons/halloween/__init__.py | 24 | ||||
-rw-r--r-- | bot/seasons/pride/__init__.py | 36 | ||||
-rw-r--r-- | bot/seasons/season.py | 560 | ||||
-rw-r--r-- | bot/seasons/valentines/__init__.py | 22 | ||||
-rw-r--r-- | bot/seasons/wildcard/__init__.py | 31 | ||||
-rw-r--r-- | bot/utils/__init__.py | 24 | ||||
-rw-r--r-- | bot/utils/decorators.py | 320 | ||||
-rw-r--r-- | bot/utils/exceptions.py | 4 | ||||
-rw-r--r-- | bot/utils/pagination.py (renamed from bot/pagination.py) | 0 | ||||
-rw-r--r-- | bot/utils/persist.py | 4 | ||||
-rw-r--r-- | docker-compose.yml | 1 |
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) @@ -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 |