diff options
Diffstat (limited to 'bot/seasons')
| -rw-r--r-- | bot/seasons/__init__.py | 206 | ||||
| -rw-r--r-- | bot/seasons/christmas/__init__.py | 33 | ||||
| -rw-r--r-- | bot/seasons/easter/__init__.py | 35 | ||||
| -rw-r--r-- | bot/seasons/easter/egg_facts.py | 15 | ||||
| -rw-r--r-- | bot/seasons/evergreen/__init__.py | 17 | ||||
| -rw-r--r-- | bot/seasons/evergreen/error_handler.py | 4 | ||||
| -rw-r--r-- | bot/seasons/halloween/__init__.py | 24 | ||||
| -rw-r--r-- | bot/seasons/halloween/candy_collection.py | 5 | ||||
| -rw-r--r-- | bot/seasons/halloween/halloween_facts.py | 9 | ||||
| -rw-r--r-- | bot/seasons/halloween/spookyreact.py | 4 | ||||
| -rw-r--r-- | bot/seasons/pride/__init__.py | 36 | ||||
| -rw-r--r-- | bot/seasons/pride/pride_facts.py | 13 | ||||
| -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 |
15 files changed, 221 insertions, 793 deletions
diff --git a/bot/seasons/__init__.py b/bot/seasons/__init__.py index 7faf9164..f9c89279 100644 --- a/bot/seasons/__init__.py +++ b/bot/seasons/__init__.py @@ -1,14 +1,206 @@ import logging +import pkgutil +from datetime import datetime +from pathlib import Path +from typing import List, Optional, Set, Type -from discord.ext import commands +from bot.constants import Colours, Month -from bot.seasons.season import SeasonBase, SeasonManager, get_season - -__all__ = ("SeasonBase", "get_season") +__all__ = ( + "SeasonBase", + "Christmas", + "Easter", + "Halloween", + "Pride", + "Valentines", + "Wildcard", + "get_seasons", + "get_extensions", + "get_current_season", + "get_season", +) log = logging.getLogger(__name__) -def setup(bot: commands.Bot) -> None: - bot.add_cog(SeasonManager(bot)) - log.info("SeasonManager cog loaded") +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: 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_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_extensions() -> List[str]: + """ + Give a list of 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. + """ + base_path = Path("bot", "seasons") + extensions = [] + + for package in pkgutil.iter_modules([base_path]): + + if package.ispkg: + package_path = base_path.joinpath(package.name) + + for module in pkgutil.iter_modules([package_path]): + extensions.append(f"bot.seasons.{package.name}.{module.name}") + else: + extensions.append(f"bot.seasons.{package.name}") + + return extensions + + +def get_current_season() -> 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) -> Optional[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/christmas/__init__.py b/bot/seasons/christmas/__init__.py index 4287efb7..e69de29b 100644 --- a/bot/seasons/christmas/__init__.py +++ b/bot/seasons/christmas/__init__.py @@ -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 index dd60bf5c..e69de29b 100644 --- a/bot/seasons/easter/__init__.py +++ b/bot/seasons/easter/__init__.py @@ -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/easter/egg_facts.py b/bot/seasons/easter/egg_facts.py index e66e25a3..f61f9da4 100644 --- a/bot/seasons/easter/egg_facts.py +++ b/bot/seasons/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.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,11 @@ 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.""" 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 +55,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/evergreen/__init__.py b/bot/seasons/evergreen/__init__.py index b3d0dc63..e69de29b 100644 --- a/bot/seasons/evergreen/__init__.py +++ b/bot/seasons/evergreen/__init__.py @@ -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/evergreen/error_handler.py b/bot/seasons/evergreen/error_handler.py index 0d8bb0bb..ba6ca5ec 100644 --- a/bot/seasons/evergreen/error_handler.py +++ b/bot/seasons/evergreen/error_handler.py @@ -7,7 +7,7 @@ from discord import Embed, Message from discord.ext import commands from bot.constants import Colours, ERROR_REPLIES, NEGATIVE_REPLIES -from bot.decorators import InChannelCheckFailure +from bot.decorators import InChannelCheckFailure, InMonthCheckFailure log = logging.getLogger(__name__) @@ -55,7 +55,7 @@ class CommandErrorHandler(commands.Cog): if isinstance(error, commands.CommandNotFound): return - if isinstance(error, InChannelCheckFailure): + 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/halloween/__init__.py b/bot/seasons/halloween/__init__.py index c81879d7..e69de29b 100644 --- a/bot/seasons/halloween/__init__.py +++ b/bot/seasons/halloween/__init__.py @@ -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/halloween/candy_collection.py b/bot/seasons/halloween/candy_collection.py index 490609dd..3c65a745 100644 --- a/bot/seasons/halloween/candy_collection.py +++ b/bot/seasons/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.decorators import in_month_listener log = logging.getLogger(__name__) @@ -35,6 +36,7 @@ class CandyCollection(commands.Cog): self.get_candyinfo[userid] = userinfo @commands.Cog.listener() + @in_month_listener(Month.october) async def on_message(self, message: discord.Message) -> None: """Randomly adds candy or skull reaction to non-bot messages in the Event channel.""" # make sure its a human message @@ -56,6 +58,7 @@ class CandyCollection(commands.Cog): return await message.add_reaction('\N{CANDY}') @commands.Cog.listener() + @in_month_listener(Month.october) async def on_reaction_add(self, reaction: discord.Reaction, user: discord.Member) -> None: """Add/remove candies from a person if the reaction satisfies criteria.""" message = reaction.message diff --git a/bot/seasons/halloween/halloween_facts.py b/bot/seasons/halloween/halloween_facts.py index 94730d9e..222768f4 100644 --- a/bot/seasons/halloween/halloween_facts.py +++ b/bot/seasons/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/spookyreact.py b/bot/seasons/halloween/spookyreact.py index 90b1254d..c6127298 100644 --- a/bot/seasons/halloween/spookyreact.py +++ b/bot/seasons/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.decorators import in_month_listener + log = logging.getLogger(__name__) SPOOKY_TRIGGERS = { @@ -24,6 +27,7 @@ class SpookyReact(Cog): self.bot = bot @Cog.listener() + @in_month_listener(Month.october) async def on_message(self, ctx: discord.Message) -> None: """ A command to send the seasonalbot github project. diff --git a/bot/seasons/pride/__init__.py b/bot/seasons/pride/__init__.py index 08df2fa1..e69de29b 100644 --- a/bot/seasons/pride/__init__.py +++ b/bot/seasons/pride/__init__.py @@ -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/pride/pride_facts.py b/bot/seasons/pride/pride_facts.py index 5c19dfd0..417a49a6 100644 --- a/bot/seasons/pride/pride_facts.py +++ b/bot/seasons/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.decorators import seasonal_task log = logging.getLogger(__name__) @@ -25,18 +24,19 @@ 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.""" 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 +101,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/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 index 6e5d16f7..e69de29b 100644 --- a/bot/seasons/valentines/__init__.py +++ b/bot/seasons/valentines/__init__.py @@ -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", - ) |