diff options
| author | 2019-10-02 20:58:00 +0100 | |
|---|---|---|
| committer | 2019-10-02 20:58:00 +0100 | |
| commit | 1da22f8a9bd64f13883a4bc8011c9f5069b4dac9 (patch) | |
| tree | deaed1b2caf7f650f05e12613cdff5b8a12629c9 /bot/seasons/season.py | |
| parent | Removed unused json (diff) | |
| parent | Merge pull request #285 from Numerlor/hacktober-date-channel-fixes (diff) | |
Merge pull request #3 from python-discord/master
Update
Diffstat (limited to 'bot/seasons/season.py')
| -rw-r--r-- | bot/seasons/season.py | 164 |
1 files changed, 84 insertions, 80 deletions
diff --git a/bot/seasons/season.py b/bot/seasons/season.py index 6d99b77f..3546fda6 100644 --- a/bot/seasons/season.py +++ b/bot/seasons/season.py @@ -6,13 +6,14 @@ import inspect import logging import pkgutil from pathlib import Path -from typing import List, Optional, Type, Union +from typing import List, Optional, Tuple, Type, Union import async_timeout import discord from discord.ext import commands -from bot.constants import Channels, Client, Roles, bot +from bot.bot import bot +from bot.constants import Channels, Client, Roles from bot.decorators import with_role log = logging.getLogger(__name__) @@ -22,10 +23,9 @@ ICON_BASE_URL = "https://raw.githubusercontent.com/python-discord/branding/maste def get_seasons() -> List[str]: """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.name) return seasons @@ -33,7 +33,6 @@ def get_seasons() -> List[str]: 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) @@ -41,7 +40,6 @@ def get_season_class(season_name: str) -> Type["SeasonBase"]: 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.") @@ -83,15 +81,16 @@ class SeasonBase: end_date: Optional[str] = None colour: Optional[int] = None - icon: str = "/logos/logo_full/logo_full.png" + 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 @@ -101,7 +100,6 @@ class SeasonBase: 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) @@ -113,7 +111,6 @@ class SeasonBase: 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) @@ -121,13 +118,11 @@ class SeasonBase: @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 @@ -138,28 +133,28 @@ class SeasonBase: 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) -> bytes: + 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 - if avatar: - icon = self.bot_icon or self.icon - else: - icon = self.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() + return (await resp.read(), icon) async def apply_username(self, *, debug: bool = False) -> Union[bool, None]: """ @@ -171,7 +166,6 @@ class SeasonBase: 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 @@ -183,12 +177,12 @@ class SeasonBase: else: if bot.user.name != self.bot_name: - # attempt to change user details + # 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 + # 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) @@ -196,7 +190,7 @@ class SeasonBase: else: result = True - # remove nickname if an old one exists + # 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) @@ -209,22 +203,21 @@ class SeasonBase: Returns True if successful. """ - - # track old avatar hash for later comparison + # Track old avatar hash for later comparison old_avatar = bot.user.avatar - # attempt the change - log.debug(f"Changing avatar to {self.bot_icon or self.icon}") - icon = await self.get_icon(avatar=True) + # 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 {self.bot_icon or self.icon}") + log.debug(f"Avatar changed to {name}") return True - log.warning(f"Changing avatar failed: {self.bot_icon or self.icon}") + log.warning(f"Changing avatar failed: {name}") return False async def apply_server_icon(self) -> bool: @@ -233,36 +226,52 @@ class SeasonBase: Returns True if was successful. """ - guild = bot.get_guild(Client.guild) - # track old icon hash for later comparison + # 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() + # 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 {self.icon}") + log.debug(f"Server icon changed to {name}") return True - log.warning(f"Changing server icon failed: {self.icon}") + log.warning(f"Changing server icon failed: {name}") return False - async def announce_season(self): + 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. 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": + # Don't actually announce if reverting to normal season + if self.name in ("evergreen", "wildcard", "halloween"): log.debug(f"Season Changed: {self.name}") return @@ -270,11 +279,11 @@ class SeasonBase: channel = guild.get_channel(Channels.announcements) mention = f"<@&{Roles.announcements}>" - # build cog info output + # 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 + # No announcement message found if not doc: return @@ -282,9 +291,9 @@ class SeasonBase: embed.set_author(name=self.greeting) if self.icon: - embed.set_image(url=ICON_BASE_URL+self.icon) + embed.set_image(url=ICON_BASE_URL+self.icon[0]) - # find any seasonal commands + # Find any seasonal commands cogs = [] for cog in bot.cogs.values(): if "evergreen" in cog.__module__: @@ -294,7 +303,7 @@ class SeasonBase: cogs.append(cog_name) if cogs: - def cog_name(cog): + def cog_name(cog: commands.Cog) -> str: return type(cog).__name__ cog_info = [] @@ -311,19 +320,19 @@ class SeasonBase: await channel.send(mention, embed=embed) - async def load(self): + 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) + 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}") @@ -352,7 +361,7 @@ class SeasonBase: class SeasonManager(commands.Cog): """A cog for managing seasons.""" - def __init__(self, bot): + 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()) @@ -369,37 +378,37 @@ class SeasonManager(commands.Cog): ) self.sleep_time = (midnight - datetime.datetime.now()).seconds + 60 - async def load_seasons(self): + 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() while True: - await asyncio.sleep(self.sleep_time) # sleep until midnight - self.sleep_time = 86400 # next time, sleep for 24 hours. + await asyncio.sleep(self.sleep_time) # Sleep until midnight + self.sleep_time = 86400 # Next time, sleep for 24 hours. # 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() + else: + await self.season.change_server_icon() @with_role(Roles.moderator, Roles.admin, Roles.owner) @commands.command(name="season") - async def change_season(self, ctx, new_season: str): + 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): + 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]): + # 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 @@ -420,11 +429,11 @@ class SeasonManager(commands.Cog): else: period = f"{start} to {end}" - # bold period if current date matches season date range + # 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 + # Underline currently active season is_active = current_season == season.name sdec = "__" if is_active else "" @@ -439,16 +448,15 @@ class SeasonManager(commands.Cog): @with_role(Roles.moderator, Roles.admin, Roles.owner) @commands.group() - async def refresh(self, ctx): + 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): + async def refresh_avatar(self, ctx: commands.Context) -> None: """Re-applies the bot avatar for the currently loaded season.""" - - # attempt the change + # Attempt the change is_changed = await self.season.apply_avatar() if is_changed: @@ -458,7 +466,7 @@ class SeasonManager(commands.Cog): colour = discord.Colour.red() title = "Avatar Failed to Refresh" - # report back details + # 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}", @@ -469,10 +477,9 @@ class SeasonManager(commands.Cog): await ctx.send(embed=embed) @refresh.command(name="icon") - async def refresh_server_icon(self, ctx): + async def refresh_server_icon(self, ctx: commands.Context) -> None: """Re-applies the server icon for the currently loaded season.""" - - # attempt the change + # Attempt the change is_changed = await self.season.apply_server_icon() if is_changed: @@ -482,7 +489,7 @@ class SeasonManager(commands.Cog): colour = discord.Colour.red() title = "Server Icon Failed to Refresh" - # report back details + # Report back details season_name = type(self.season).__name__ embed = discord.Embed( description=f"**Season:** {season_name}\n**Icon:** {self.season.icon}", @@ -493,13 +500,12 @@ class SeasonManager(commands.Cog): await ctx.send(embed=embed) @refresh.command(name="username", aliases=("name",)) - async def refresh_username(self, ctx): + 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 + # Attempt the change is_changed = await self.season.apply_username() if is_changed: @@ -511,7 +517,7 @@ class SeasonManager(commands.Cog): else: colour = discord.Colour.red() - # if None, it's because it wasn't meant to change username + # If None, it's because it wasn't meant to change username if is_changed is None: title = "Nickname Refreshed" else: @@ -520,7 +526,7 @@ class SeasonManager(commands.Cog): old_name = old_display_name new_name = self.season.bot_name - # report back details + # Report back details season_name = type(self.season).__name__ embed = discord.Embed( description=f"**Season:** {season_name}\n" @@ -533,12 +539,10 @@ class SeasonManager(commands.Cog): @with_role(Roles.moderator, Roles.admin, Roles.owner) @commands.command() - async def announce(self, ctx): + async def announce(self, ctx: commands.Context) -> None: """Announces the currently loaded season.""" - await self.season.announce_season() - def cog_unload(self): + def cog_unload(self) -> None: """Cancel season-related tasks on cog unload.""" - self.season_task.cancel() |