diff options
| -rw-r--r-- | bot/__main__.py | 9 | ||||
| -rw-r--r-- | bot/bot.py | 148 | ||||
| -rw-r--r-- | bot/constants.py | 97 | ||||
| -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) | 10 | ||||
| -rw-r--r-- | bot/exts/christmas/hanukkah_embed.py (renamed from bot/seasons/christmas/hanukkah_embed.py) | 5 | ||||
| -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) | 0 | ||||
| -rw-r--r-- | bot/exts/easter/avatar_easterifier.py (renamed from bot/seasons/easter/avatar_easterifier.py) | 0 | ||||
| -rw-r--r-- | bot/exts/easter/bunny_name_generator.py (renamed from bot/seasons/easter/bunny_name_generator.py) | 0 | ||||
| -rw-r--r-- | bot/exts/easter/conversationstarters.py (renamed from bot/seasons/easter/conversationstarters.py) | 0 | ||||
| -rw-r--r-- | bot/exts/easter/easter_riddle.py (renamed from bot/seasons/easter/easter_riddle.py) | 0 | ||||
| -rw-r--r-- | bot/exts/easter/egg_decorating.py (renamed from bot/seasons/easter/egg_decorating.py) | 0 | ||||
| -rw-r--r-- | bot/exts/easter/egg_facts.py (renamed from bot/seasons/easter/egg_facts.py) | 17 | ||||
| -rw-r--r-- | bot/exts/easter/egghead_quiz.py (renamed from bot/seasons/easter/egghead_quiz.py) | 0 | ||||
| -rw-r--r-- | bot/exts/easter/traditions.py (renamed from bot/seasons/easter/traditions.py) | 0 | ||||
| -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) | 0 | ||||
| -rw-r--r-- | bot/exts/evergreen/bookmark.py (renamed from bot/seasons/evergreen/bookmark.py) | 4 | ||||
| -rw-r--r-- | bot/exts/evergreen/branding.py | 504 | ||||
| -rw-r--r-- | bot/exts/evergreen/error_handler.py (renamed from bot/seasons/evergreen/error_handler.py) | 9 | ||||
| -rw-r--r-- | bot/exts/evergreen/fun.py (renamed from bot/seasons/evergreen/fun.py) | 0 | ||||
| -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) | 7 | ||||
| -rw-r--r-- | bot/exts/evergreen/issues.py (renamed from bot/seasons/evergreen/issues.py) | 2 | ||||
| -rw-r--r-- | bot/exts/evergreen/magic_8ball.py (renamed from bot/seasons/evergreen/magic_8ball.py) | 0 | ||||
| -rw-r--r-- | bot/exts/evergreen/minesweeper.py (renamed from bot/seasons/evergreen/minesweeper.py) | 0 | ||||
| -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) | 0 | ||||
| -rw-r--r-- | bot/exts/evergreen/reddit.py (renamed from bot/seasons/evergreen/reddit.py) | 3 | ||||
| -rw-r--r-- | bot/exts/evergreen/showprojects.py (renamed from bot/seasons/evergreen/showprojects.py) | 0 | ||||
| -rw-r--r-- | bot/exts/evergreen/snakes/__init__.py (renamed from bot/seasons/evergreen/snakes/__init__.py) | 2 | ||||
| -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/speedrun.py (renamed from bot/seasons/evergreen/speedrun.py) | 0 | ||||
| -rw-r--r-- | bot/exts/evergreen/trivia_quiz.py (renamed from bot/seasons/evergreen/trivia_quiz.py) | 0 | ||||
| -rw-r--r-- | bot/exts/evergreen/uptime.py (renamed from bot/seasons/evergreen/uptime.py) | 0 | ||||
| -rw-r--r-- | bot/exts/halloween/8ball.py (renamed from bot/seasons/halloween/8ball.py) | 0 | ||||
| -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) | 6 | ||||
| -rw-r--r-- | bot/exts/halloween/hacktober-issue-finder.py (renamed from bot/seasons/halloween/hacktober-issue-finder.py) | 4 | ||||
| -rw-r--r-- | bot/exts/halloween/hacktoberstats.py (renamed from bot/seasons/halloween/hacktoberstats.py) | 8 | ||||
| -rw-r--r-- | bot/exts/halloween/halloween_facts.py (renamed from bot/seasons/halloween/halloween_facts.py) | 9 | ||||
| -rw-r--r-- | bot/exts/halloween/halloweenify.py (renamed from bot/seasons/halloween/halloweenify.py) | 0 | ||||
| -rw-r--r-- | bot/exts/halloween/monsterbio.py (renamed from bot/seasons/halloween/monsterbio.py) | 0 | ||||
| -rw-r--r-- | bot/exts/halloween/monstersurvey.py (renamed from bot/seasons/halloween/monstersurvey.py) | 0 | ||||
| -rw-r--r-- | bot/exts/halloween/scarymovie.py (renamed from bot/seasons/halloween/scarymovie.py) | 0 | ||||
| -rw-r--r-- | bot/exts/halloween/spookyavatar.py (renamed from bot/seasons/halloween/spookyavatar.py) | 0 | ||||
| -rw-r--r-- | bot/exts/halloween/spookygif.py (renamed from bot/seasons/halloween/spookygif.py) | 0 | ||||
| -rw-r--r-- | bot/exts/halloween/spookyrating.py (renamed from bot/seasons/halloween/spookyrating.py) | 0 | ||||
| -rw-r--r-- | bot/exts/halloween/spookyreact.py (renamed from bot/seasons/halloween/spookyreact.py) | 4 | ||||
| -rw-r--r-- | bot/exts/halloween/spookysound.py (renamed from bot/seasons/halloween/spookysound.py) | 0 | ||||
| -rw-r--r-- | bot/exts/halloween/timeleft.py (renamed from bot/seasons/halloween/timeleft.py) | 0 | ||||
| -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) | 0 | ||||
| -rw-r--r-- | bot/exts/pride/pride_anthem.py (renamed from bot/seasons/pride/pride_anthem.py) | 0 | ||||
| -rw-r--r-- | bot/exts/pride/pride_avatar.py (renamed from bot/seasons/pride/pride_avatar.py) | 0 | ||||
| -rw-r--r-- | bot/exts/pride/pride_facts.py (renamed from bot/seasons/pride/pride_facts.py) | 15 | ||||
| -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) | 9 | ||||
| -rw-r--r-- | bot/exts/valentines/lovecalculator.py (renamed from bot/seasons/valentines/lovecalculator.py) | 0 | ||||
| -rw-r--r-- | bot/exts/valentines/movie_generator.py (renamed from bot/seasons/valentines/movie_generator.py) | 0 | ||||
| -rw-r--r-- | bot/exts/valentines/myvalenstate.py (renamed from bot/seasons/valentines/myvalenstate.py) | 0 | ||||
| -rw-r--r-- | bot/exts/valentines/pickuplines.py (renamed from bot/seasons/valentines/pickuplines.py) | 0 | ||||
| -rw-r--r-- | bot/exts/valentines/savethedate.py (renamed from bot/seasons/valentines/savethedate.py) | 0 | ||||
| -rw-r--r-- | bot/exts/valentines/valentine_zodiac.py (renamed from bot/seasons/valentines/valentine_zodiac.py) | 0 | ||||
| -rw-r--r-- | bot/exts/valentines/whoisvalentine.py (renamed from bot/seasons/valentines/whoisvalentine.py) | 0 | ||||
| -rw-r--r-- | bot/seasons.py | 157 | ||||
| -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 | 2 | ||||
| -rw-r--r-- | bot/utils/decorators.py | 321 | ||||
| -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 | 
87 files changed, 1298 insertions, 1061 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,13 +1,17 @@ +import asyncio +import contextlib  import logging  import socket -from traceback import format_exc -from typing import List +from typing import Optional +import async_timeout +import discord  from aiohttp import AsyncResolver, ClientSession, TCPConnector  from discord import DiscordException, Embed  from discord.ext import commands  from bot.constants import Channels, Client +from bot.utils.decorators import mock_in_debug  log = logging.getLogger(__name__) @@ -15,7 +19,13 @@ __all__ = ('SeasonalBot', 'bot')  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,23 +33,6 @@ 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()}') -      async def send_log(self, title: str, details: str = None, *, icon: str = None) -> None:          """Send an embed message to the devlog channel."""          devlog = self.get_channel(Channels.devlog) @@ -63,5 +56,120 @@ class SeasonalBot(commands.Bot):          else:              await super().on_command_error(context, exception) +    @property +    def member(self) -> Optional[discord.Member]: +        """Retrieves the guild member object for the bot.""" +        guild = bot.get_guild(Client.guild) +        if not guild: +            return None +        return guild.me + +    @mock_in_debug(return_value=True) +    async def set_avatar(self, url: str) -> bool: +        """Sets the bot's avatar based on a URL.""" +        # Track old avatar hash for later comparison +        old_avatar = bot.user.avatar + +        image = await self._fetch_image(url) +        with contextlib.suppress(discord.HTTPException, asyncio.TimeoutError): +            async with async_timeout.timeout(5): +                await bot.user.edit(avatar=image) + +        if bot.user.avatar != old_avatar: +            log.debug(f"Avatar changed to {url}") +            return True + +        log.warning(f"Changing avatar failed: {url}") +        return False + +    @mock_in_debug(return_value=True) +    async def set_banner(self, url: str) -> bool: +        """Sets the guild's banner based on the provided `url`.""" +        guild = bot.get_guild(Client.guild) +        old_banner = guild.banner + +        image = await self._fetch_image(url) +        with contextlib.suppress(discord.HTTPException, asyncio.TimeoutError): +            async with async_timeout.timeout(5): +                await guild.edit(banner=image) + +        new_banner = bot.get_guild(Client.guild).banner +        if new_banner != old_banner: +            log.debug(f"Banner changed to {url}") +            return True + +        log.warning(f"Changing banner failed: {url}") +        return False + +    @mock_in_debug(return_value=True) +    async def set_icon(self, url: str) -> bool: +        """Sets the guild's icon based on a URL.""" +        guild = bot.get_guild(Client.guild) +        # Track old icon hash for later comparison +        old_icon = guild.icon + +        image = await self._fetch_image(url) +        with contextlib.suppress(discord.HTTPException, asyncio.TimeoutError): +            async with async_timeout.timeout(5): +                await guild.edit(icon=image) + +        new_icon = bot.get_guild(Client.guild).icon +        if new_icon != old_icon: +            log.debug(f"Icon changed to {url}") +            return True + +        log.warning(f"Changing icon failed: {url}") +        return False + +    async def _fetch_image(self, url: str) -> bytes: +        """Retrieve an image based on a URL.""" +        log.debug(f"Getting image from: {url}") +        async with self.http_session.get(url) as resp: +            return await resp.read() + +    @mock_in_debug(return_value=True) +    async def set_username(self, new_name: str, nick_only: bool = False) -> Optional[bool]: +        """ +        Set the bot username and/or nickname to given new name. + +        Returns True/False based on success, or None if nickname fallback also failed. +        """ +        old_username = self.user.name + +        if nick_only: +            return await self.set_nickname(new_name) + +        if old_username == new_name: +            # since the username is correct, make sure nickname is removed +            return await self.set_nickname() + +        log.debug(f"Changing username to {new_name}") +        with contextlib.suppress(discord.HTTPException): +            await bot.user.edit(username=new_name, nick=None) + +        if not new_name == self.member.display_name: +            # name didn't change, try to changing nickname as fallback +            if await self.set_nickname(new_name): +                log.warning(f"Changing username failed, changed nickname instead.") +                return False +            log.warning(f"Changing username and nickname failed.") +            return None + +        return True + +    @mock_in_debug(return_value=True) +    async def set_nickname(self, new_name: str = None) -> bool: +        """Set the bot nickname in the main guild.""" +        old_display_name = self.member.display_name + +        if old_display_name == new_name: +            return False + +        log.debug(f"Changing nickname to {new_name}") +        with contextlib.suppress(discord.HTTPException): +            await self.member.edit(nick=new_name) + +        return not old_display_name == self.member.display_name +  bot = SeasonalBot(command_prefix=Client.prefix) diff --git a/bot/constants.py b/bot/constants.py index 051bd62a..1a2763f0 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,11 @@ 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, ... +    autostart = environ.get("BRANDING_AUTOSTART", "").lower() == "true"  # Launch the branding daemon on start-up + +  class Channels(NamedTuple):      admins = 365960823622991872      advent_of_code = int(environ.get("AOC_CHANNEL_ID", 517745814039166986)) @@ -36,7 +51,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 +82,6 @@ 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  class Colours: @@ -90,6 +102,7 @@ class Emojis:      check = "\u2611"      envelope = "\U0001F4E8"      trashcan = "<:trashcan:637136429717389331>" +    ok_hand = ":ok_hand:"      terning1 = "<:terning1:431249668983488527>"      terning2 = "<:terning2:462339216987127808>" @@ -105,16 +118,35 @@ 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  class Roles(NamedTuple): @@ -140,6 +172,7 @@ class Tokens(NamedTuple):      youtube = environ.get("YOUTUBE_API_KEY")      tmdb = environ.get("TMDB_API_KEY")      igdb = environ.get("IGDB_API_KEY") +    github = environ.get("GITHUB_TOKEN")  # Default role combinations @@ -148,12 +181,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.", @@ -193,16 +241,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..f7590e04 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", diff --git a/bot/seasons/christmas/hanukkah_embed.py b/bot/exts/christmas/hanukkah_embed.py index aaa02b27..62efd04e 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).""" 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..4869f510 100644 --- a/bot/seasons/easter/april_fools_vids.py +++ b/bot/exts/easter/april_fools_vids.py diff --git a/bot/seasons/easter/avatar_easterifier.py b/bot/exts/easter/avatar_easterifier.py index e21e35fc..e21e35fc 100644 --- a/bot/seasons/easter/avatar_easterifier.py +++ b/bot/exts/easter/avatar_easterifier.py diff --git a/bot/seasons/easter/bunny_name_generator.py b/bot/exts/easter/bunny_name_generator.py index 97c467e1..97c467e1 100644 --- a/bot/seasons/easter/bunny_name_generator.py +++ b/bot/exts/easter/bunny_name_generator.py diff --git a/bot/seasons/easter/conversationstarters.py b/bot/exts/easter/conversationstarters.py index 3f38ae82..3f38ae82 100644 --- a/bot/seasons/easter/conversationstarters.py +++ b/bot/exts/easter/conversationstarters.py diff --git a/bot/seasons/easter/easter_riddle.py b/bot/exts/easter/easter_riddle.py index f5b1aac7..f5b1aac7 100644 --- a/bot/seasons/easter/easter_riddle.py +++ b/bot/exts/easter/easter_riddle.py diff --git a/bot/seasons/easter/egg_decorating.py b/bot/exts/easter/egg_decorating.py index 23df95f1..23df95f1 100644 --- a/bot/seasons/easter/egg_decorating.py +++ b/bot/exts/easter/egg_decorating.py diff --git a/bot/seasons/easter/egg_facts.py b/bot/exts/easter/egg_facts.py index e66e25a3..99a80b28 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,5 @@ class EasterFacts(commands.Cog):  def setup(bot: commands.Bot) -> None:      """Easter Egg facts cog load.""" -    bot.loop.create_task(EasterFacts(bot).send_egg_fact_daily())      bot.add_cog(EasterFacts(bot))      log.info("EasterFacts cog loaded") diff --git a/bot/seasons/easter/egghead_quiz.py b/bot/exts/easter/egghead_quiz.py index bd179fe2..bd179fe2 100644 --- a/bot/seasons/easter/egghead_quiz.py +++ b/bot/exts/easter/egghead_quiz.py diff --git a/bot/seasons/easter/traditions.py b/bot/exts/easter/traditions.py index 9529823f..9529823f 100644 --- a/bot/seasons/easter/traditions.py +++ b/bot/exts/easter/traditions.py 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..9b8aaa48 100644 --- a/bot/seasons/evergreen/battleship.py +++ b/bot/exts/evergreen/battleship.py diff --git a/bot/seasons/evergreen/bookmark.py b/bot/exts/evergreen/bookmark.py index bd7d5c11..e703e07b 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) diff --git a/bot/exts/evergreen/branding.py b/bot/exts/evergreen/branding.py new file mode 100644 index 00000000..2eb563ea --- /dev/null +++ b/bot/exts/evergreen/branding.py @@ -0,0 +1,504 @@ +import asyncio +import itertools +import logging +import random +import typing as t +from datetime import datetime, time, timedelta + +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_current_season, get_season +from bot.utils.decorators import with_role +from bot.utils.exceptions import BrandingError + +log = logging.getLogger(__name__) +log.setLevel(logging.DEBUG) + +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": "seasonal-structure"}  # 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 + + +async def pretty_files(files: t.Iterable[GithubFile]) -> str: +    """Provide a human-friendly representation of `files`.""" +    return "\n".join(file.path for file in files) + + +async 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. + +    The cog is designed to be entirely autonomous. The `daemon` background task awakens once +    a day (see `time_until_midnight`) to detect new seasons, or to cycle icons within a single +    season. If the `Branding.autostart` constant is True, the `daemon` will launch on start-up, +    otherwise it can be controlled via the `daemon` cmd group. + +    All supported operations, e.g. setting seasons, applying the branding, or cycling icons, can +    also be invoked manually, via the following API: + +        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] + +    should_cycle: t.Iterator + +    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.should_cycle = itertools.cycle([False]) + +        self.available_icons = [] +        self.remaining_icons = [] + +        if Branding.autostart: +            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() + +    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.should_cycle): +                await self.cycle() + +            until_midnight = await 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: +            active_months = ", ".join(m.name for m in self.current_season.months) +            title = f"{self.current_season.season_name} ({active_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 = await 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_should_cycle(self) -> None: +        """ +        Reset the `should_cycle` counter based on configured frequency. + +        Counter will always yield False if either holds: +            - Branding.cycle_frequency is falsey +            - There are fewer than 2 available icons for current season + +        Cycling can be easily turned off, and we prevent re-uploading the same icon repeatedly. +        """ +        if len(self.available_icons) > 1 and Branding.cycle_frequency: +            wait_period = [False] * (Branding.cycle_frequency - 1) +            counter = itertools.cycle(wait_period + [True]) +        else: +            counter = itertools.cycle([False]) + +        self.should_cycle = counter + +    async def _get_files(self, path: str, include_dirs: bool = False) -> t.Dict[str, GithubFile]: +        """ +        Poll `path` in branding repo for information about present files. + +        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: +        """ +        Poll Github API to refresh currently available icons. + +        If the current season is not the evergreen, and lacks at least one asset, +        we also poll 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_should_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: +            await self._reset_remaining_icons() +            log.info(f"Set remaining icons: {await pretty_files(self.remaining_icons)}") + +        next_up, *self.remaining_icons = self.remaining_icons +        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="info", aliases=["status"]) +    async def branding_info(self, ctx: commands.Context) -> None: +        """ +        Show 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: +        """ +        Refresh current season from branding repository. + +        Polls Github API to refresh assets available for current season. +        """ +        async with ctx.typing(): +            await self.refresh() +            await self.branding_info(ctx) + +    @branding_cmds.command(name="cycle") +    async def branding_cycle(self, ctx: commands.Context) -> None: +        """ +        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.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="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 +        async with ctx.typing(): +            await self.refresh() +            await self.branding_info(ctx) + +    @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() + await 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()) +        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() +        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)) +    log.info("BrandingManager cog loaded") diff --git a/bot/seasons/evergreen/error_handler.py b/bot/exts/evergreen/error_handler.py index 2753a6df..f756c321 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 diff --git a/bot/seasons/evergreen/fun.py b/bot/exts/evergreen/fun.py index 889ae079..889ae079 100644 --- a/bot/seasons/evergreen/fun.py +++ b/bot/exts/evergreen/fun.py diff --git a/bot/seasons/evergreen/game.py b/bot/exts/evergreen/game.py index e6700937..d43b1ad6 100644 --- a/bot/seasons/evergreen/game.py +++ b/bot/exts/evergreen/game.py @@ -12,8 +12,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..f4d76402 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):      """ @@ -537,6 +540,8 @@ def setup(bot: SeasonalBot) -> None:      except Exception:          unload(bot)          raise +    else: +        log.info("Help cog loaded")  def teardown(bot: SeasonalBot) -> None: diff --git a/bot/seasons/evergreen/issues.py b/bot/exts/evergreen/issues.py index fba5b174..fb18b62a 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__) diff --git a/bot/seasons/evergreen/magic_8ball.py b/bot/exts/evergreen/magic_8ball.py index e47ef454..e47ef454 100644 --- a/bot/seasons/evergreen/magic_8ball.py +++ b/bot/exts/evergreen/magic_8ball.py diff --git a/bot/seasons/evergreen/minesweeper.py b/bot/exts/evergreen/minesweeper.py index b0ba8145..b0ba8145 100644 --- a/bot/seasons/evergreen/minesweeper.py +++ b/bot/exts/evergreen/minesweeper.py 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..835a4e53 100644 --- a/bot/seasons/evergreen/recommend_game.py +++ b/bot/exts/evergreen/recommend_game.py diff --git a/bot/seasons/evergreen/reddit.py b/bot/exts/evergreen/reddit.py index 32ca419a..a07e591f 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__) diff --git a/bot/seasons/evergreen/showprojects.py b/bot/exts/evergreen/showprojects.py index a943e548..a943e548 100644 --- a/bot/seasons/evergreen/showprojects.py +++ b/bot/exts/evergreen/showprojects.py diff --git a/bot/seasons/evergreen/snakes/__init__.py b/bot/exts/evergreen/snakes/__init__.py index d7f9f20c..06ebfb47 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__) 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/speedrun.py b/bot/exts/evergreen/speedrun.py index 76c5e8d3..76c5e8d3 100644 --- a/bot/seasons/evergreen/speedrun.py +++ b/bot/exts/evergreen/speedrun.py diff --git a/bot/seasons/evergreen/trivia_quiz.py b/bot/exts/evergreen/trivia_quiz.py index 99b64497..99b64497 100644 --- a/bot/seasons/evergreen/trivia_quiz.py +++ b/bot/exts/evergreen/trivia_quiz.py diff --git a/bot/seasons/evergreen/uptime.py b/bot/exts/evergreen/uptime.py index 6f24f545..6f24f545 100644 --- a/bot/seasons/evergreen/uptime.py +++ b/bot/exts/evergreen/uptime.py diff --git a/bot/seasons/halloween/8ball.py b/bot/exts/halloween/8ball.py index 2e1c2804..2e1c2804 100644 --- a/bot/seasons/halloween/8ball.py +++ b/bot/exts/halloween/8ball.py 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..3f2b895e 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.""" diff --git a/bot/seasons/halloween/hacktober-issue-finder.py b/bot/exts/halloween/hacktober-issue-finder.py index 10732374..f15a665a 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:          """ diff --git a/bot/seasons/halloween/hacktoberstats.py b/bot/exts/halloween/hacktoberstats.py index d61e048b..5dfa2f51 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: diff --git a/bot/seasons/halloween/halloween_facts.py b/bot/exts/halloween/halloween_facts.py index 94730d9e..222768f4 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) diff --git a/bot/seasons/halloween/halloweenify.py b/bot/exts/halloween/halloweenify.py index dfcc2b1e..dfcc2b1e 100644 --- a/bot/seasons/halloween/halloweenify.py +++ b/bot/exts/halloween/halloweenify.py diff --git a/bot/seasons/halloween/monsterbio.py b/bot/exts/halloween/monsterbio.py index bfa8a026..bfa8a026 100644 --- a/bot/seasons/halloween/monsterbio.py +++ b/bot/exts/halloween/monsterbio.py diff --git a/bot/seasons/halloween/monstersurvey.py b/bot/exts/halloween/monstersurvey.py index 12e1d022..12e1d022 100644 --- a/bot/seasons/halloween/monstersurvey.py +++ b/bot/exts/halloween/monstersurvey.py diff --git a/bot/seasons/halloween/scarymovie.py b/bot/exts/halloween/scarymovie.py index 3823a3e4..3823a3e4 100644 --- a/bot/seasons/halloween/scarymovie.py +++ b/bot/exts/halloween/scarymovie.py diff --git a/bot/seasons/halloween/spookyavatar.py b/bot/exts/halloween/spookyavatar.py index 268de3fb..268de3fb 100644 --- a/bot/seasons/halloween/spookyavatar.py +++ b/bot/exts/halloween/spookyavatar.py diff --git a/bot/seasons/halloween/spookygif.py b/bot/exts/halloween/spookygif.py index 818de8cd..818de8cd 100644 --- a/bot/seasons/halloween/spookygif.py +++ b/bot/exts/halloween/spookygif.py diff --git a/bot/seasons/halloween/spookyrating.py b/bot/exts/halloween/spookyrating.py index 7f78f536..7f78f536 100644 --- a/bot/seasons/halloween/spookyrating.py +++ b/bot/exts/halloween/spookyrating.py diff --git a/bot/seasons/halloween/spookyreact.py b/bot/exts/halloween/spookyreact.py index 90b1254d..16f18019 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:          """ diff --git a/bot/seasons/halloween/spookysound.py b/bot/exts/halloween/spookysound.py index e0676d0a..e0676d0a 100644 --- a/bot/seasons/halloween/spookysound.py +++ b/bot/exts/halloween/spookysound.py diff --git a/bot/seasons/halloween/timeleft.py b/bot/exts/halloween/timeleft.py index 8cb3f4f6..8cb3f4f6 100644 --- a/bot/seasons/halloween/timeleft.py +++ b/bot/exts/halloween/timeleft.py 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..43813fbd 100644 --- a/bot/seasons/pride/drag_queen_name.py +++ b/bot/exts/pride/drag_queen_name.py diff --git a/bot/seasons/pride/pride_anthem.py b/bot/exts/pride/pride_anthem.py index b0c6d34e..b0c6d34e 100644 --- a/bot/seasons/pride/pride_anthem.py +++ b/bot/exts/pride/pride_anthem.py diff --git a/bot/seasons/pride/pride_avatar.py b/bot/exts/pride/pride_avatar.py index 85e49d5c..85e49d5c 100644 --- a/bot/seasons/pride/pride_avatar.py +++ b/bot/exts/pride/pride_avatar.py diff --git a/bot/seasons/pride/pride_facts.py b/bot/exts/pride/pride_facts.py index 5c19dfd0..2db8f5c2 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,5 @@ class PrideFacts(commands.Cog):  def setup(bot: commands.Bot) -> None:      """Cog loader for pride facts.""" -    bot.loop.create_task(PrideFacts(bot).send_pride_fact_daily())      bot.add_cog(PrideFacts(bot))      log.info("Pride facts cog loaded!") diff --git a/bot/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..1e883d21 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: diff --git a/bot/seasons/valentines/lovecalculator.py b/bot/exts/valentines/lovecalculator.py index 03d3d7d5..03d3d7d5 100644 --- a/bot/seasons/valentines/lovecalculator.py +++ b/bot/exts/valentines/lovecalculator.py diff --git a/bot/seasons/valentines/movie_generator.py b/bot/exts/valentines/movie_generator.py index ce1d7d5b..ce1d7d5b 100644 --- a/bot/seasons/valentines/movie_generator.py +++ b/bot/exts/valentines/movie_generator.py diff --git a/bot/seasons/valentines/myvalenstate.py b/bot/exts/valentines/myvalenstate.py index 0256c39a..0256c39a 100644 --- a/bot/seasons/valentines/myvalenstate.py +++ b/bot/exts/valentines/myvalenstate.py diff --git a/bot/seasons/valentines/pickuplines.py b/bot/exts/valentines/pickuplines.py index 8b2c9822..8b2c9822 100644 --- a/bot/seasons/valentines/pickuplines.py +++ b/bot/exts/valentines/pickuplines.py diff --git a/bot/seasons/valentines/savethedate.py b/bot/exts/valentines/savethedate.py index e0bc3904..e0bc3904 100644 --- a/bot/seasons/valentines/savethedate.py +++ b/bot/exts/valentines/savethedate.py diff --git a/bot/seasons/valentines/valentine_zodiac.py b/bot/exts/valentines/valentine_zodiac.py index c8d77e75..c8d77e75 100644 --- a/bot/seasons/valentines/valentine_zodiac.py +++ b/bot/exts/valentines/valentine_zodiac.py diff --git a/bot/seasons/valentines/whoisvalentine.py b/bot/exts/valentines/whoisvalentine.py index b8586dca..b8586dca 100644 --- a/bot/seasons/valentines/whoisvalentine.py +++ b/bot/exts/valentines/whoisvalentine.py diff --git a/bot/seasons.py b/bot/seasons.py new file mode 100644 index 00000000..83a584fd --- /dev/null +++ b/bot/seasons.py @@ -0,0 +1,157 @@ +import logging +import typing as t +from datetime import datetime + +from bot.constants import Colours, Month + +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_current_season() -> t.Type[SeasonBase]: +    """Give active season, based on current UTC month.""" +    current_month = Month(datetime.utcnow().month) + +    active_seasons = tuple( +        season +        for season in SeasonBase.__subclasses__() +        if current_month in season.months +    ) + +    if not active_seasons: +        return SeasonBase + +    if len(active_seasons) > 1: +        log.warning(f"Multiple active season in month {current_month.name}") + +    return active_seasons[0] + + +def get_season(name: str) -> 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 [SeasonBase] + SeasonBase.__subclasses__(): +        matches = (season.__name__.casefold(), season.season_name.casefold()) + +        if name in matches: +            return season 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..cb5c9fbe 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -7,7 +7,7 @@ from typing import List  import discord  from discord.ext.commands import BadArgument, Context -from bot.pagination import LinePaginator +from bot.utils.pagination import LinePaginator  async def disambiguate( diff --git a/bot/utils/decorators.py b/bot/utils/decorators.py new file mode 100644 index 00000000..f85996b5 --- /dev/null +++ b/bot/utils/decorators.py @@ -0,0 +1,321 @@ +import asyncio +import functools +import logging +import random +import typing as t +from asyncio import Lock +from datetime import datetime +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 + +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__} ({allowed_months})") + +            while True: +                current_month = Month(datetime.utcnow().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.name}") + +                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 = Month(datetime.utcnow().month) + +            if current_month in allowed_months: +                # Propagate return value although it should always be None +                return await listener(*args, **kwargs) +            else: +                log.debug(f"Guarded {listener.__qualname__} from invoking in {current_month.name}") +        return guarded_listener +    return decorator + + +def in_month_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 = datetime.utcnow().month +        can_run = current_month in allowed_months + +        human_months = ", ".join(m.name for m in allowed_months) +        log.debug( +            f"Command '{ctx.command}' is locked to months {human_months}. " +            f"Invoking it in month {current_month} is {'allowed' if can_run else 'disallowed'}." +        ) +        if can_run: +            return True +        else: +            raise InMonthCheckFailure(f"Command can only be used in {human_months}") + +    return commands.check(predicate) + + +def 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 {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 {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 | 
