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
-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.png Binary files differdeleted 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.png Binary files differdeleted 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.png Binary files differdeleted 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() |