diff options
| author | 2018-12-04 20:05:07 +0100 | |
|---|---|---|
| committer | 2018-12-04 20:05:07 +0100 | |
| commit | a3b92a8d1224aaafe2fd141843f5763688ca8027 (patch) | |
| tree | 4d4edcad152214752d2e34fca1f512e4c9f7e741 | |
| parent | Merge pull request #87 from python-discord/bytecommander-aoc2 (diff) | |
| parent | Use contextlib.suppress, remove unnecessary `u` prefix. (diff) | |
Merge pull request #80 from scragly/season_improvements
Season Improvements
Diffstat (limited to '')
| -rw-r--r-- | bot/constants.py | 52 | ||||
| -rw-r--r-- | bot/resources/avatars/christmas.png | bin | 44843 -> 0 bytes | |||
| -rw-r--r-- | bot/resources/avatars/spooky.png | bin | 37202 -> 0 bytes | |||
| -rw-r--r-- | bot/resources/avatars/standard.png | bin | 52156 -> 0 bytes | |||
| -rw-r--r-- | bot/seasons/christmas/__init__.py | 21 | ||||
| -rw-r--r-- | bot/seasons/christmas/adventofcode.py | 5 | ||||
| -rw-r--r-- | bot/seasons/evergreen/__init__.py | 10 | ||||
| -rw-r--r-- | bot/seasons/halloween/__init__.py | 14 | ||||
| -rw-r--r-- | bot/seasons/season.py | 451 | 
9 files changed, 461 insertions, 92 deletions
| diff --git a/bot/constants.py b/bot/constants.py index 1294912a..71bdbf5f 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -4,14 +4,27 @@ from typing import NamedTuple  from bot.bot import SeasonalBot -__all__ = ('Client', 'Roles', 'bot') +__all__ = ( +    "AdventOfCode", "Channels", "Client", "Colours", "Emojis", "Hacktoberfest", "Roles", +    "Tokens", "bot" +)  log = logging.getLogger(__name__) +class AdventOfCode: +    leaderboard_cache_age_threshold_seconds = 3600 +    leaderboard_id = 363275 +    leaderboard_join_code = "363275-442b6939" +    leaderboard_max_displayed_members = 10 +    year = 2018 +    channel_id = int(environ.get("AOC_CHANNEL_ID", 517745814039166986)) +    role_id = int(environ.get("AOC_ROLE_ID", 518565788744024082)) + +  class Channels(NamedTuple):      admins = 365960823622991872 -    announcements = 354619224620138496 +    announcements = int(environ.get('CHANNEL_ANNOUNCEMENTS', 354619224620138496))      big_brother_logs = 468507907357409333      bot = 267659945086812160      checkpoint_test = 422077681434099723 @@ -45,13 +58,26 @@ class Client(NamedTuple):      season_override = environ.get('SEASON_OVERRIDE') +class Colours: +    soft_red = 0xcd6d6d +    soft_green = 0x68c290 +    dark_green = 0x1f8b4c +    orange = 0xe67e22 + + +class Emojis: +    star = "\u2B50" +    christmas_tree = "\U0001F384" +    check = "\u2611" + +  class Hacktoberfest(NamedTuple):      channel_id = 498804484324196362      voice_id = 514420006474219521  class Roles(NamedTuple): -    admin = 267628507062992896 +    admin = int(environ.get('SEASONALBOT_ADMIN_ROLE_ID', 267628507062992896))      announcements = 463658397560995840      champion = 430492892331769857      contributor = 295488872404484098 @@ -66,29 +92,9 @@ class Roles(NamedTuple):      rockstars = 458226413825294336 -class Colours: -    soft_red = 0xcd6d6d -    soft_green = 0x68c290 - - -class Emojis: -    star = "\u2B50" -    christmas_tree = u"\U0001F384" - -  class Tokens(NamedTuple):      giphy = environ.get("GIPHY_TOKEN")      aoc_session_cookie = environ.get("AOC_SESSION_COOKIE") -class AdventOfCode: -    leaderboard_cache_age_threshold_seconds = 3600 -    leaderboard_id = 363275 -    leaderboard_join_code = "363275-442b6939" -    leaderboard_max_displayed_members = 10 -    year = 2018 -    channel_id = int(environ.get("AOC_CHANNEL_ID", 517745814039166986)) -    role_id = int(environ.get("AOC_ROLE_ID", 518565788744024082)) - -  bot = SeasonalBot(command_prefix=Client.prefix) diff --git a/bot/resources/avatars/christmas.png b/bot/resources/avatars/christmas.pngBinary files differ deleted file mode 100644 index 55b72fac..00000000 --- a/bot/resources/avatars/christmas.png +++ /dev/null diff --git a/bot/resources/avatars/spooky.png b/bot/resources/avatars/spooky.pngBinary files differ deleted file mode 100644 index 4ab33188..00000000 --- a/bot/resources/avatars/spooky.png +++ /dev/null diff --git a/bot/resources/avatars/standard.png b/bot/resources/avatars/standard.pngBinary files differ deleted file mode 100644 index c14ff42a..00000000 --- a/bot/resources/avatars/standard.png +++ /dev/null diff --git a/bot/seasons/christmas/__init__.py b/bot/seasons/christmas/__init__.py index cd5ce307..99d81b0c 100644 --- a/bot/seasons/christmas/__init__.py +++ b/bot/seasons/christmas/__init__.py @@ -1,16 +1,21 @@ +from bot.constants import Colours  from bot.seasons import SeasonBase  class Christmas(SeasonBase): +    """ +    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 = "31/12" -    bot_name = "Santabot" - -    def __init__(self, bot): -        self.bot = bot -    @property -    def bot_avatar(self): -        with open(self.avatar_path("christmas.png"), "rb") as avatar: -            return bytearray(avatar.read()) +    colour = Colours.dark_green +    icon = "/logos/logo_seasonal/christmas/festive.png" diff --git a/bot/seasons/christmas/adventofcode.py b/bot/seasons/christmas/adventofcode.py index 58d083ab..fcd3e171 100644 --- a/bot/seasons/christmas/adventofcode.py +++ b/bot/seasons/christmas/adventofcode.py @@ -108,6 +108,9 @@ async def day_countdown(bot: commands.Bot):  class AdventOfCode: +    """ +    Advent of Code festivities! Ho Ho Ho! +    """      def __init__(self, bot: commands.Bot):          self.bot = bot @@ -133,7 +136,7 @@ class AdventOfCode:      @commands.group(name="adventofcode", aliases=("aoc",), invoke_without_command=True)      async def adventofcode_group(self, ctx: commands.Context):          """ -        Advent of Code festivities! Ho Ho Ho! +        All of the Advent of Code commands          """          await ctx.invoke(self.bot.get_command("help"), "adventofcode") diff --git a/bot/seasons/evergreen/__init__.py b/bot/seasons/evergreen/__init__.py index e4367aaa..db5b5684 100644 --- a/bot/seasons/evergreen/__init__.py +++ b/bot/seasons/evergreen/__init__.py @@ -2,12 +2,4 @@ from bot.seasons import SeasonBase  class Evergreen(SeasonBase): -    bot_name = "SeasonalBot" - -    def __init__(self, bot): -        self.bot = bot - -    @property -    def bot_avatar(self): -        with open(self.avatar_path("standard.png"), "rb") as avatar: -            return bytearray(avatar.read()) +    pass diff --git a/bot/seasons/halloween/__init__.py b/bot/seasons/halloween/__init__.py index 40b9ce90..4b371f14 100644 --- a/bot/seasons/halloween/__init__.py +++ b/bot/seasons/halloween/__init__.py @@ -1,16 +1,14 @@ +from bot.constants import Colours  from bot.seasons import SeasonBase  class Halloween(SeasonBase):      name = "halloween" -    start_date = "01/10" -    end_date = "31/10"      bot_name = "Spookybot" +    greeting = "Happy Halloween!" -    def __init__(self, bot): -        self.bot = bot +    start_date = "01/10" +    end_date = "31/10" -    @property -    def bot_avatar(self): -        with open(self.avatar_path("spooky.png"), "rb") as avatar: -            return bytearray(avatar.read()) +    colour = Colours.orange +    icon = "/logos/logo_seasonal/halloween/spooky.png" diff --git a/bot/seasons/season.py b/bot/seasons/season.py index 591bbc2d..f1d570e0 100644 --- a/bot/seasons/season.py +++ b/bot/seasons/season.py @@ -1,38 +1,46 @@  import asyncio +import contextlib  import datetime  import importlib +import inspect  import logging  import pkgutil  from pathlib import Path +from typing import List, Optional, Type, Union +import async_timeout +import discord  from discord.ext import commands -from bot.constants import Client, Roles +from bot.constants import Channels, Client, Roles, bot  from bot.decorators import with_role  log = logging.getLogger(__name__) -def get_seasons(): +def get_seasons() -> List[str]:      """ -    Returns all the Season objects -    located in bot/seasons/ +    Returns all the Season objects located in bot/seasons/      """ +      seasons = [] -    for module in pkgutil.iter_modules([Path('bot', 'seasons')]): +    for module in pkgutil.iter_modules([Path("bot", "seasons")]):          if module.ispkg: -            seasons.append(module[1]) - +            seasons.append(module.name)      return seasons -def get_season_class(season_name): -    season_lib = importlib.import_module(f'bot.seasons.{season_name}') +def get_season_class(season_name: str) -> Type["SeasonBase"]: +    """ +    Get's the season class of the season module. +    """ + +    season_lib = importlib.import_module(f"bot.seasons.{season_name}")      return getattr(season_lib, season_name.capitalize()) -def get_season(bot, season_name: str = None, date: datetime.date = None): +def get_season(season_name: str = None, date: datetime.datetime = None) -> "SeasonBase":      """      Returns a Season object based on either a string or a date.      """ @@ -52,82 +60,283 @@ def get_season(bot, season_name: str = None, date: datetime.date = None):      if season_name:          season_name = season_name.lower()          if season_name not in seasons: -            season_name = 'evergreen' +            season_name = "evergreen"          season_class = get_season_class(season_name) -        return season_class(bot) +        return season_class()      # If not, we have to figure out if the date matches any of the seasons. -    seasons.remove('evergreen') +    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.start() <= date <= season_class.end(): -            return season_class(bot) +        if season_class.is_between_dates(date): +            return season_class()      else: -        evergreen_class = get_season_class('evergreen') -        return evergreen_class(bot) +        evergreen_class = get_season_class("evergreen") +        return evergreen_class()  class SeasonBase: -    name = None -    date_format = "%d/%m-%Y" +    """ +    Base class for Seasonal classes. +    """ + +    name: Optional[str] = "evergreen" +    bot_name: str = "SeasonalBot" + +    start_date: Optional[str] = None +    end_date: Optional[str] = None + +    colour: Optional[int] = None +    icon: str = "/logos/logo_full/logo_full.png" + +    date_format: str = "%d/%m/%Y"      @staticmethod -    def current_year(): +    def current_year() -> int: +        """ +        Returns the current year. +        """ +          return datetime.date.today().year      @classmethod -    def start(cls): -        return datetime.datetime.strptime(f"{cls.start_date}-{cls.current_year()}", cls.date_format).date() +    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): -        return datetime.datetime.strptime(f"{cls.end_date}-{cls.current_year()}", cls.date_format).date() +    def end(cls) -> datetime.datetime: +        """ +        Returns the start date using current year and end_date attribute. -    @staticmethod -    def avatar_path(*path_segments): -        return Path('bot', 'resources', 'avatars', *path_segments) +        If no end_date was defined, returns the minimum datetime to ensure +        it's always above checked dates. +        """ -    async def load(self): +        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 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 inhertiting class. +        """ + +        season_name = " ".join(self.name.split("_")).title() +        return f"New Season, {season_name}!" + +    async def get_icon(self) -> bytes:          """ -        Loads in the bot name, the bot avatar, -        and the extensions that are relevant to that season. +        Retrieves the icon image from the branding repository, using the +        defined icon attribute for the season. + +        The icon attribute must provide the url path, starting from the master +        branch base url, including the starting slash: +        `https://raw.githubusercontent.com/python-discord/branding/master` +        """ + +        base_url = "https://raw.githubusercontent.com/python-discord/branding/master" +        full_url = base_url + self.icon +        log.debug(f"Getting icon from: {full_url}") +        async with bot.http_session.get(full_url) as resp: +            return await resp.read() + +    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 = self.bot.get_guild(Client.guild) +        guild = bot.get_guild(Client.guild) +        result = None          # Change only nickname if in debug mode due to ratelimits for user edits -        if Client.debug: +        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 self.bot.user.name != self.bot_name: +            if bot.user.name != self.bot_name:                  # attempt to change user details                  log.debug(f"Changing username to {self.bot_name}") -                await self.bot.user.edit(name=self.bot_name, avatar=self.bot_avatar) +                with contextlib.suppress(discord.HTTPException): +                    await bot.user.edit(username=self.bot_name)                  # fallback on nickname if failed due to ratelimit -                if self.bot.user.name != self.bot_name: -                    log.info(f"User details failed to change: Changing nickname to {self.bot_name}") +                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 if it was successful. +        """ + +        # track old avatar hash for later comparison +        old_avatar = bot.user.avatar + +        # attempt the change +        log.debug(f"Changing avatar to {self.icon}") +        icon = await self.get_icon() +        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 {self.icon}") +            return True + +        log.warning(f"Changing avatar failed: {self.icon}") +        return False + +    async def apply_server_icon(self) -> bool: +        """ +        Applies the server icon for the current season. Returns if it was successful. +        """ + +        guild = bot.get_guild(Client.guild) + +        # track old icon hash for later comparison +        old_icon = guild.icon + +        # attempt the change +        log.debug(f"Changing server icon to {self.icon}") +        icon = await self.get_icon() +        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 {self.icon}") +            return True + +        log.warning(f"Changing server icon failed: {self.icon}") +        return False + +    async def announce_season(self): +        """ +        Announces a change in season in the announcement channel. + +        It will skip the announcement if the current active season is the +        "evergreen" default season. +        """ + +        # don't actually announce if reverting to normal season +        if self.name == "evergreen": +            log.debug(f"Season Changed: {self.name}") +            return + +        guild = bot.get_guild(Client.guild) +        channel = guild.get_channel(Channels.announcements) +        mention = f"<@&{Roles.announcements}>" + +        # collect seasonal cogs +        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) + +        # no cogs, so no seasonal commands +        if not cogs: +            return + +        # build cog info output +        doc = inspect.getdoc(self) +        announce_text = doc + "\n\n" if doc else "" + +        def cog_name(cog): +            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}**") + +        embed = discord.Embed(description=announce_text, colour=self.colour or guild.me.colour) +        embed.set_author(name=self.greeting) +        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): +        """ +        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. +        """ +          # 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) +                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. -        self.bot.load_extensions(extensions) +        bot.load_extensions(extensions) + +        # Apply seasonal elements after extensions successfully load +        username_changed = await self.apply_username(debug=Client.debug) + +        # Avoid heavy API ratelimited elements when debugging +        if not Client.debug: +            log.info("Applying avatar.") +            await self.apply_avatar() +            log.info("Applying server icon.") +            await self.apply_server_icon() +            if username_changed: +                log.info(f"Announcing season {self.name}.") +                await self.announce_season() +            else: +                log.info(f"Skipping season announcement due to username not being changed.")  class SeasonManager: @@ -137,7 +346,7 @@ class SeasonManager:      def __init__(self, bot):          self.bot = bot -        self.season = get_season(bot, date=datetime.date.today()) +        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 @@ -161,20 +370,176 @@ class SeasonManager:              self.sleep_time = 86400  # next time, sleep for 24 hours.              # If the season has changed, load it. -            new_season = get_season(self.bot, date=datetime.date.today()) +            new_season = get_season(date=datetime.datetime.utcnow())              if new_season != self.season:                  await self.season.load()      @with_role(Roles.moderator, Roles.admin, Roles.owner) -    @commands.command(name='season') +    @commands.command(name="season")      async def change_season(self, ctx, new_season: str):          """          Changes the currently active season on the bot.          """ -        self.season = get_season(self.bot, season_name=new_season) +        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): +        """ +        Shows the available seasons and their dates. +        """ + +        # sort by start order, followed by lower duration +        def season_key(season: SeasonBase): +            return season.start(), season.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): +        """ +        Refreshes certain seasonal elements without reloading seasons. +        """ +        if not ctx.invoked_subcommand: +            await ctx.invoke(bot.get_command("help"), "refresh") + +    @refresh.command(name="avatar") +    async def refresh_avatar(self, ctx): +        """ +        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.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): +        """ +        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): +        """ +        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): +        """ +        Announces the currently loaded season. +        """ +        await self.season.announce_season() +      def __unload(self):          self.season_task.cancel() | 
