diff options
| author | 2019-10-02 20:58:00 +0100 | |
|---|---|---|
| committer | 2019-10-02 20:58:00 +0100 | |
| commit | 1da22f8a9bd64f13883a4bc8011c9f5069b4dac9 (patch) | |
| tree | deaed1b2caf7f650f05e12613cdff5b8a12629c9 /bot | |
| 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')
103 files changed, 2604 insertions, 1586 deletions
diff --git a/bot/__init__.py b/bot/__init__.py index 7c564178..4729e50c 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -13,14 +13,13 @@ logging.TRACE = 5 logging.addLevelName(logging.TRACE, "TRACE") -def monkeypatch_trace(self, msg, *args, **kwargs): +def monkeypatch_trace(self: logging.Logger, msg: str, *args, **kwargs) -> None: """ Log 'msg % args' with severity 'TRACE'. To pass exception information, use the keyword argument exc_info with a true value, e.g. logger.trace("Houston, we have an %s", "interesting problem", exc_info=1) """ - if self.isEnabledFor(logging.TRACE): self._log(logging.TRACE, msg, args, **kwargs) @@ -31,7 +30,7 @@ logging.Logger.trace = monkeypatch_trace start_time = arrow.utcnow() # Set up file logging -log_dir = Path("bot", "log") +log_dir = Path("bot/log") log_file = log_dir / "hackbot.log" os.makedirs(log_dir, exist_ok=True) diff --git a/bot/__main__.py b/bot/__main__.py index a3b68ec1..9dc0b173 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -1,8 +1,11 @@ import logging -from bot.constants import Client, bot +from bot.bot import bot +from bot.constants import Client, STAFF_ROLES, WHITELISTED_CHANNELS +from bot.decorators import in_channel_check log = logging.getLogger(__name__) +bot.add_check(in_channel_check(*WHITELISTED_CHANNELS, bypass_roles=STAFF_ROLES)) bot.load_extension("bot.seasons") bot.run(Client.token) @@ -4,14 +4,14 @@ from traceback import format_exc from typing import List from aiohttp import AsyncResolver, ClientSession, TCPConnector -from discord import Embed +from discord import DiscordException, Embed from discord.ext import commands -from bot import constants +from bot.constants import Channels, Client log = logging.getLogger(__name__) -__all__ = ('SeasonalBot',) +__all__ = ('SeasonalBot', 'bot') class SeasonalBot(commands.Bot): @@ -23,9 +23,8 @@ class SeasonalBot(commands.Bot): connector=TCPConnector(resolver=AsyncResolver(), family=socket.AF_INET) ) - def load_extensions(self, exts: List[str]): + def load_extensions(self, exts: List[str]) -> None: """Unload all current extensions, then load the given extensions.""" - # Unload all cogs extensions = list(self.extensions.keys()) for extension in extensions: @@ -41,10 +40,9 @@ class SeasonalBot(commands.Bot): except Exception as e: log.error(f'Failed to load extension {cog}: {repr(e)} {format_exc()}') - async def send_log(self, title: str, details: str = None, *, icon: str = None): + async def send_log(self, title: str, details: str = None, *, icon: str = None) -> None: """Send an embed message to the devlog channel.""" - - devlog = self.get_channel(constants.Channels.devlog) + devlog = self.get_channel(Channels.devlog) if not devlog: log.warning("Log failed to send. Devlog channel not found.") @@ -58,10 +56,12 @@ class SeasonalBot(commands.Bot): await devlog.send(embed=embed) - async def on_command_error(self, context, exception): + async def on_command_error(self, context: commands.Context, exception: DiscordException) -> None: """Check command errors for UserInputError and reset the cooldown if thrown.""" - if isinstance(exception, commands.UserInputError): context.command.reset_cooldown(context) else: await super().on_command_error(context, exception) + + +bot = SeasonalBot(command_prefix=Client.prefix) diff --git a/bot/constants.py b/bot/constants.py index 67dd9328..0d4321c8 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -2,11 +2,10 @@ import logging from os import environ from typing import NamedTuple -from bot.bot import SeasonalBot - __all__ = ( - "AdventOfCode", "Channels", "Client", "Colours", "Emojis", "Hacktoberfest", "Roles", - "Tokens", "ERROR_REPLIES", "bot" + "AdventOfCode", "Channels", "Client", "Colours", "Emojis", "Hacktoberfest", "Roles", "Tokens", + "WHITELISTED_CHANNELS", "STAFF_ROLES", "MODERATION_ROLES", + "POSITIVE_REPLIES", "NEGATIVE_REPLIES", "ERROR_REPLIES", ) log = logging.getLogger(__name__) @@ -23,12 +22,12 @@ class AdventOfCode: class Channels(NamedTuple): admins = 365960823622991872 - announcements = int(environ.get('CHANNEL_ANNOUNCEMENTS', 354619224620138496)) + announcements = int(environ.get("CHANNEL_ANNOUNCEMENTS", 354619224620138496)) big_brother_logs = 468507907357409333 bot = 267659945086812160 checkpoint_test = 422077681434099723 devalerts = 460181980097675264 - devlog = int(environ.get('CHANNEL_DEVLOG', 548438471685963776)) + devlog = int(environ.get("CHANNEL_DEVLOG", 548438471685963776)) devtest = 414574275865870337 help_0 = 303906576991780866 help_1 = 303906556754395136 @@ -45,20 +44,23 @@ class Channels(NamedTuple): off_topic_2 = 463035268514185226 python = 267624335836053506 reddit = 458224812528238616 - seasonalbot_chat = int(environ.get('CHANNEL_SEASONALBOT_CHAT', 542272993192050698)) + seasonalbot_chat = int(environ.get("CHANNEL_SEASONALBOT_CHAT", 542272993192050698)) + seasonalbot_commands = int(environ.get("CHANNEL_SEASONALBOT_COMMANDS", 607247579608121354)) + seasonalbot_voice = int(environ.get("CHANNEL_SEASONALBOT_VOICE", 606259004230074378)) staff_lounge = 464905259261755392 verification = 352442727016693763 python_discussion = 267624335836053506 - show_your_projects = 303934982764625920 + show_your_projects = int(environ.get("CHANNEL_SHOW_YOUR_PROJECTS", 303934982764625920)) show_your_projects_discussion = 360148304664723466 + hacktoberfest_2019 = 628184417646411776 class Client(NamedTuple): - guild = int(environ.get('SEASONALBOT_GUILD', 267624335836053506)) + guild = int(environ.get("SEASONALBOT_GUILD", 267624335836053506)) prefix = environ.get("PREFIX", ".") - token = environ.get('SEASONALBOT_TOKEN') - debug = environ.get('SEASONALBOT_DEBUG', '').lower() == 'true' - season_override = environ.get('SEASON_OVERRIDE') + token = environ.get("SEASONALBOT_TOKEN") + debug = environ.get("SEASONALBOT_DEBUG", "").lower() == "true" + season_override = environ.get("SEASON_OVERRIDE") class Colours: @@ -80,7 +82,7 @@ class Emojis: terning1 = "<:terning1:431249668983488527>" terning2 = "<:terning2:462339216987127808>" terning3 = "<:terning3:431249694467948544>" - terning4 = "<:terning4:431249704769290241>" + terning4 = "<:terning4:579980271475228682>" terning5 = "<:terning5:431249716328792064>" terning6 = "<:terning6:431249726705369098>" @@ -94,7 +96,7 @@ class Hacktoberfest(NamedTuple): class Roles(NamedTuple): - admin = int(environ.get('SEASONALBOT_ADMIN_ROLE_ID', 267628507062992896)) + admin = int(environ.get("SEASONALBOT_ADMIN_ROLE_ID", 267628507062992896)) announcements = 463658397560995840 champion = 430492892331769857 contributor = 295488872404484098 @@ -116,6 +118,58 @@ class Tokens(NamedTuple): youtube = environ.get("YOUTUBE_API_KEY") +# Default role combinations +MODERATION_ROLES = Roles.moderator, Roles.admin, Roles.owner +STAFF_ROLES = Roles.helpers, Roles.moderator, Roles.admin, Roles.owner + +# Whitelisted channels +WHITELISTED_CHANNELS = ( + Channels.bot, Channels.seasonalbot_commands, + Channels.off_topic_0, Channels.off_topic_1, Channels.off_topic_2, + Channels.devtest, +) + +# Bot replies +NEGATIVE_REPLIES = [ + "Noooooo!!", + "Nope.", + "I'm sorry Dave, I'm afraid I can't do that.", + "I don't think so.", + "Not gonna happen.", + "Out of the question.", + "Huh? No.", + "Nah.", + "Naw.", + "Not likely.", + "No way, José.", + "Not in a million years.", + "Fat chance.", + "Certainly not.", + "NEGATORY.", + "Nuh-uh.", + "Not in my house!", +] + +POSITIVE_REPLIES = [ + "Yep.", + "Absolutely!", + "Can do!", + "Affirmative!", + "Yeah okay.", + "Sure.", + "Sure thing!", + "You're the boss!", + "Okay.", + "No problem.", + "I got you.", + "Alright.", + "You got it!", + "ROGER THAT", + "Of course!", + "Aye aye, cap'n!", + "I'll allow it.", +] + ERROR_REPLIES = [ "Please don't do that.", "You have to stop.", @@ -128,6 +182,3 @@ ERROR_REPLIES = [ "Noooooo!!", "I can't believe you've done this", ] - - -bot = SeasonalBot(command_prefix=Client.prefix) diff --git a/bot/decorators.py b/bot/decorators.py index 15f7fed2..2c042b56 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -1,25 +1,33 @@ import logging import random +import typing from asyncio import Lock from functools import wraps from weakref import WeakValueDictionary from discord import Colour, Embed from discord.ext import commands -from discord.ext.commands import Context +from discord.ext.commands import CheckFailure, Context from bot.constants import ERROR_REPLIES log = logging.getLogger(__name__) -def with_role(*role_ids: int): - """Check to see whether the invoking user has any of the roles specified in role_ids.""" +class InChannelCheckFailure(CheckFailure): + """Check failure when the user runs a command in a non-whitelisted channel.""" + + pass - async def predicate(ctx: Context): + +def with_role(*role_ids: int) -> bool: + """Check to see whether the invoking user has any of the roles specified in role_ids.""" + async def predicate(ctx: Context) -> bool: if not ctx.guild: # Return False in a DM - log.debug(f"{ctx.author} tried to use the '{ctx.command.name}'command from a DM. " - "This command is restricted by the with_role decorator. Rejecting request.") + log.debug( + f"{ctx.author} tried to use the '{ctx.command.name}'command from a DM. " + "This command is restricted by the with_role decorator. Rejecting request." + ) return False for role in ctx.author.roles: @@ -27,41 +35,116 @@ def with_role(*role_ids: int): log.debug(f"{ctx.author} has the '{role.name}' role, and passes the check.") return True - log.debug(f"{ctx.author} does not have the required role to use " - f"the '{ctx.command.name}' command, so the request is rejected.") + log.debug( + f"{ctx.author} does not have the required role to use " + f"the '{ctx.command.name}' command, so the request is rejected." + ) return False return commands.check(predicate) -def without_role(*role_ids: int): +def without_role(*role_ids: int) -> bool: """Check whether the invoking user does not have all of the roles specified in role_ids.""" - - async def predicate(ctx: Context): + async def predicate(ctx: Context) -> bool: if not ctx.guild: # Return False in a DM - log.debug(f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM. " - "This command is restricted by the without_role decorator. Rejecting request.") + log.debug( + f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM. " + "This command is restricted by the without_role decorator. Rejecting request." + ) return False author_roles = [role.id for role in ctx.author.roles] check = all(role not in author_roles for role in role_ids) - log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. " - f"The result of the without_role check was {check}.") + log.debug( + f"{ctx.author} tried to call the '{ctx.command.name}' command. " + f"The result of the without_role check was {check}." + ) return check return commands.check(predicate) -def in_channel(channel_id): - """Check that the command invocation is in the channel specified by channel_id.""" +def in_channel_check(*channels: int, bypass_roles: typing.Container[int] = None) -> typing.Callable[[Context], bool]: + """ + Checks that the message is in a whitelisted channel or optionally has a bypass role. - async def predicate(ctx: Context): - check = ctx.channel.id == channel_id - log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. " - f"The result of the in_channel check was {check}.") - return check - return commands.check(predicate) + If `in_channel_override` is present, check if it contains channels + and use them in place of the global whitelist. + """ + def predicate(ctx: Context) -> bool: + if not ctx.guild: + log.debug(f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM.") + return True + if ctx.channel.id in channels: + log.debug( + f"{ctx.author} tried to call the '{ctx.command.name}' command " + f"and the command was used in a whitelisted channel." + ) + return True + + if hasattr(ctx.command.callback, "in_channel_override"): + override = ctx.command.callback.in_channel_override + if override is None: + log.debug( + f"{ctx.author} called the '{ctx.command.name}' command " + f"and the command was whitelisted to bypass the in_channel check." + ) + return True + else: + if ctx.channel.id in override: + log.debug( + f"{ctx.author} tried to call the '{ctx.command.name}' command " + f"and the command was used in an overridden whitelisted channel." + ) + return True + + log.debug( + f"{ctx.author} tried to call the '{ctx.command.name}' command. " + f"The overridden in_channel check failed." + ) + channels_str = ', '.join(f"<#{c_id}>" for c_id in override) + raise InChannelCheckFailure( + f"Sorry, but you may only use this command within {channels_str}." + ) + + if bypass_roles and any(r.id in bypass_roles for r in ctx.author.roles): + log.debug( + f"{ctx.author} called the '{ctx.command.name}' command and " + f"had a role to bypass the in_channel check." + ) + return True + log.debug( + f"{ctx.author} tried to call the '{ctx.command.name}' command. " + f"The in_channel check failed." + ) -def locked(): + channels_str = ', '.join(f"<#{c_id}>" for c_id in channels) + raise InChannelCheckFailure( + f"Sorry, but you may only use this command within {channels_str}." + ) + + return predicate + + +in_channel = commands.check(in_channel_check) + + +def override_in_channel(channels: typing.Tuple[int] = None) -> typing.Callable: + """ + Set command callback attribute for detection in `in_channel_check`. + + Override global whitelist if channels are specified. + + This decorator has to go before (below) below the `command` decorator. + """ + def inner(func: typing.Callable) -> typing.Callable: + func.in_channel_override = channels + return func + + return inner + + +def locked() -> typing.Union[typing.Callable, None]: """ Allows the user to only run one instance of the decorated command at a time. @@ -69,12 +152,11 @@ def locked(): This decorator has to go before (below) the `command` decorator. """ - - def wrap(func): + def wrap(func: typing.Callable) -> typing.Union[typing.Callable, None]: func.__locks = WeakValueDictionary() @wraps(func) - async def inner(self, ctx, *args, **kwargs): + async def inner(self: typing.Callable, ctx: Context, *args, **kwargs) -> typing.Union[typing.Callable, None]: lock = func.__locks.setdefault(ctx.author.id, Lock()) if lock.locked(): embed = Embed() diff --git a/bot/pagination.py b/bot/pagination.py index 1091878a..f1233482 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -22,28 +22,17 @@ class EmptyPaginatorEmbed(Exception): class LinePaginator(Paginator): - """ - A class that aids in paginating code blocks for Discord messages. - - Attributes - ----------- - prefix: :class:`str` - The prefix inserted to every page. e.g. three backticks. - suffix: :class:`str` - The suffix appended at the end of every page. e.g. three backticks. - max_size: :class:`int` - The maximum amount of codepoints allowed in a page. - max_lines: :class:`int` - The maximum amount of lines allowed in a page. - """ + """A class that aids in paginating code blocks for Discord messages.""" - def __init__(self, prefix='```', suffix='```', max_size=2000, max_lines=None): + def __init__(self, prefix: str = '```', suffix: str = '```', max_size: int = 2000, max_lines: int = None): """ Overrides the Paginator.__init__ from inside discord.ext.commands. - Allows for configuration of the maximum number of lines per page. - """ + `prefix` and `suffix` will be prepended and appended respectively to every page. + `max_size` and `max_lines` denote the maximum amount of codepoints and lines + allowed per page. + """ self.prefix = prefix self.suffix = suffix self.max_size = max_size - len(suffix) @@ -53,28 +42,17 @@ class LinePaginator(Paginator): self._count = len(prefix) + 1 # prefix + newline self._pages = [] - def add_line(self, line='', *, empty=False): + def add_line(self, line: str = '', *, empty: bool = False) -> None: """ Adds a line to the current page. - If the line exceeds the `max_size` then an exception is raised. + If the line exceeds the `max_size` then a RuntimeError is raised. Overrides the Paginator.add_line from inside discord.ext.commands in order to allow configuration of the maximum number of lines per page. - Parameters - ----------- - line: str - The line to add. - empty: bool - Indicates if another empty line should be added. - - Raises - ------ - RuntimeError - The line was too big for the current `max_size`. + If `empty` is True, an empty line will be placed after the a given `line`. """ - if len(line) > self.max_size - len(self.prefix) - 2: raise RuntimeError('Line exceeds maximum page size %s' % (self.max_size - len(self.prefix) - 2)) @@ -107,7 +85,11 @@ class LinePaginator(Paginator): When used, this will send a message using `ctx.send()` and apply a set of reactions to it. These reactions may be used to change page, or to remove pagination from the message. - Pagination will also be removed automatically if no reaction is added for five minutes (300 seconds). + + Pagination will also be removed automatically if no reaction is added for `timeout` seconds, + defaulting to five minutes (300 seconds). + + If `empty` is True, an empty line will be placed between each given line. >>> embed = Embed() >>> embed.set_author(name="Some Operation", url=url, icon_url=icon) @@ -115,25 +97,9 @@ class LinePaginator(Paginator): ... (line for line in lines), ... ctx, embed ... ) - - :param lines: The lines to be paginated - :param ctx: Current context object - :param embed: A pre-configured embed to be used as a template for each page - :param prefix: Text to place before each page - :param suffix: Text to place after each page - :param max_lines: The maximum number of lines on each page - :param max_size: The maximum number of characters on each page - :param empty: Whether to place an empty line between each given line - :param restrict_to_user: A user to lock pagination operations to for this message, if supplied - :param exception_on_empty_embed: Should there be an exception if the embed is empty? - :param url: the url to use for the embed headline - :param timeout: The amount of time in seconds to disable pagination of no reaction is added - :param footer_text: Text to prefix the page number in the footer with """ - - def event_check(reaction_: Reaction, user_: Member): + def event_check(reaction_: Reaction, user_: Member) -> bool: """Make sure that this reaction is what we want to operate on.""" - no_restrictions = ( # Pagination is not restricted not restrict_to_user @@ -308,7 +274,7 @@ class ImagePaginator(Paginator): Refer to ImagePaginator.paginate for documentation on how to use. """ - def __init__(self, prefix="", suffix=""): + def __init__(self, prefix: str = "", suffix: str = ""): super().__init__(prefix, suffix) self._current_page = [prefix] self.images = [] @@ -318,10 +284,8 @@ class ImagePaginator(Paginator): """ Adds a line to each page, usually just 1 line in this context. - :param line: str to be page content / title - :param empty: if there should be new lines between entries + If `empty` is True, an empty line will be placed after a given `line`. """ - if line: self._count = len(line) else: @@ -330,12 +294,7 @@ class ImagePaginator(Paginator): self.close_page() def add_image(self, image: str = None) -> None: - """ - Adds an image to a page. - - :param image: image url to be appended - """ - + """Adds an image to a page given the url.""" self.images.append(image) @classmethod @@ -345,35 +304,21 @@ class ImagePaginator(Paginator): """ Use a paginator and set of reactions to provide pagination over a set of title/image pairs. - The reactions are used to switch page, or to finish with pagination. + `pages` is a list of tuples of page title/image url pairs. + `prefix` and `suffix` will be prepended and appended respectively to the message. When used, this will send a message using `ctx.send()` and apply a set of reactions to it. These reactions may be used to change page, or to remove pagination from the message. - Note: Pagination will be removed automatically if no reaction is added for five minutes (300 seconds). + Note: Pagination will be removed automatically if no reaction is added for `timeout` seconds, + defaulting to five minutes (300 seconds). >>> embed = Embed() >>> embed.set_author(name="Some Operation", url=url, icon_url=icon) >>> await ImagePaginator.paginate(pages, ctx, embed) - - Parameters - ----------- - :param pages: An iterable of tuples with title for page, and img url - :param ctx: ctx for message - :param embed: base embed to modify - :param prefix: prefix of message - :param suffix: suffix of message - :param timeout: timeout for when reactions get auto-removed """ - def check_event(reaction_: Reaction, member: Member) -> bool: - """ - Checks each reaction added, if it matches our conditions pass the wait_for. - - :param reaction_: reaction added - :param member: reaction added by member - """ - + """Checks each reaction added, if it matches our conditions pass the wait_for.""" return all(( # Reaction is on the same message sent reaction_.message.id == message.id, diff --git a/bot/resources/easter/bunny_names.json b/bot/resources/easter/bunny_names.json new file mode 100644 index 00000000..8c97169c --- /dev/null +++ b/bot/resources/easter/bunny_names.json @@ -0,0 +1,29 @@ +{ + "names": [ + "Flopsy", + "Hopsalot", + "Thumper", + "Nibbles", + "Daisy", + "Fuzzy", + "Cottontail", + "Carrot Top", + "Marshmallow", + "Lucky", + "Clover", + "Daffodil", + "Buttercup", + "Goldie", + "Dizzy", + "Trixie", + "Snuffles", + "Hopscotch", + "Skipper", + "Thunderfoot", + "Bigwig", + "Dandelion", + "Pipkin", + "Buckthorn", + "Skipper" + ] +} diff --git a/bot/resources/easter/easter_egg_facts.json b/bot/resources/easter/easter_egg_facts.json new file mode 100644 index 00000000..b0650b84 --- /dev/null +++ b/bot/resources/easter/easter_egg_facts.json @@ -0,0 +1,17 @@ +[ + "The first story of a rabbit (later named the \"Easter Bunny\") hiding eggs in a garden was published in 1680.", + "Rabbits are known to be prolific pro creators and are an ancient symbol of fertility and new life. The German immigrants brought the tale of Easter Bunny in the 1700s with the tradition of an egg-laying hare called \"Osterhase\". The kids then would make nests in which the creature would lay coloured eggs. The tradition has been revolutionized in the form of candies and gifts instead of eggs.", + "In earlier days, a festival of egg throwing was held in church, when the priest would throw a hard-boiled egg to one of the choirboys. It was then tossed from one choirboy to the next and whoever held the egg when the clock struck 12 on Easter, was the winner and could keep it.", + "In medieval times, Easter eggs were boiled with onions to give them a golden sheen. Edward I went beyond this tradition in 1290 and ordered 450 eggs to be covered in gold leaf and given as Easter gifts.", + "Decorating Easter eggs is an ancient tradition that dates back to 13th century. One of the explanations for this custom is that eggs were considered as a forbidden food during the Lenten season (40 days before Easter). Therefore, people would paint and decorate them to mark an end of the period of penance and fasting and later eat them on Easter. The tradition of decorating eggs is called Pysanka which is creating a traditional Ukrainian folk design using wax-resist method.", + "Members of the Greek Orthodox faith often paint their Easter eggs red, which symbolizes Jesus' blood and his victory over death. The color red, symbolizes renewal of life, such as, Jesus' resurrection.", + "Eggs rolling take place in many parts of the world which symbolizes stone which was rolled away from the tomb where Jesus' body was laid after his death.", + "Easter eggs have been considered as a symbol of fertility, rebirth and new life. The custom of giving eggs has been derived from Egyptians, Persians, Gauls, Greeks, and Romans.", + "The first chocolate Easter egg was made by Fry's in 1873. Before this, people would give hollow cardboard eggs, filled with gifts.", + "The tallest chocolate Easter egg was made in Italy in 2011. Standing 10.39 metres tall and weighing 7,200 kg, it was taller than a giraffe and heavier than an elephant.", + "The largest ever Easter egg hunt was in Florida, where 9,753 children searched for 501,000 eggs.", + "In 2007, an Easter egg covered in diamonds sold for almost £9 million. Every hour, a cockerel made of jewels pops up from the top of the Faberge egg, flaps its wings four times, nods its head three times and makes a crowing noise. The gold-and-pink enamel egg was made by the Russian royal family as an engagement gift for French aristocrat Baron Edouard de Rothschild.", + "The White House held their first official egg roll in 1878 when Rutherford B. Hayes was the President. It is a race in which children push decorated, hard-boiled eggs across the White House lawn as an annual event held the Monday after Easter. In 2009, the Obamas hosted their first Easter egg roll with the theme, \"Let's go play\" which was meant to encourage young people to lead healthy and active lives.", + "80 million chocolate Easter eggs are sold each year. This accounts for 10% of Britain's annual spending on chocolate!", + "John Cadbury soon followed suit and made his first Cadbury Easter egg in 1875. By 1892 the company was producing 19 different lines, all made from dark chocolate." +] diff --git a/bot/resources/easter/easter_riddle.json b/bot/resources/easter/easter_riddle.json new file mode 100644 index 00000000..e93f6dad --- /dev/null +++ b/bot/resources/easter/easter_riddle.json @@ -0,0 +1,82 @@ +[ + { + "question": "What kind of music do bunnies like?", + "riddles": [ + "Two words", + "Jump to the beat" + ], + "correct_answer": "Hip hop" + }, + { + "question": "What kind of jewelry do rabbits wear?", + "riddles": [ + "They can eat it too", + "14 ___ gold" + ], + "correct_answer": "14 carrot gold" + }, + { + "question": "What does the easter bunny get for making a basket?", + "riddles": [ + "KOBE!", + "1+1 = ?" + ], + "correct_answer": "2 points" + }, + { + "question": "Where does the easter bunny eat breakfast?", + "riddles": [ + "No waffles here", + "An international home" + ], + "correct_answer": "IHOP" + }, + { + "question": "What do you call a rabbit with fleas?", + "riddles": [ + "A bit of a looney tune", + "What's up Doc?" + ], + "correct_answer": "Bugs Bunny" + }, + { + "question": "Why was the little girl sad after the race?", + "riddles": [ + "2nd place?", + "Who beat her?" + ], + "correct_answer": "Because an egg beater" + }, + { + "question": "What happened to the Easter Bunny when he misbehaved at school?", + "riddles": [ + "Won't be back anymore", + "Worse than suspension" + ], + "correct_answer": "He was eggspelled" + }, + { + "question": "What kind of bunny can't hop?", + "riddles": [ + "Might melt in the sun", + "Fragile and yummy" + ], + "correct_answer": "A chocolate one" + }, + { + "question": "Where does the Easter Bunny get his eggs?", + "riddles": [ + "Not a bush or tree", + "Emoji for a body part" + ], + "correct_answer": "Eggplants" + }, + { + "question": "Why did the Easter Bunny have to fire the duck?", + "riddles": [ + "Quack", + "MY EGGS!!" + ], + "correct_answer": "He kept quacking the eggs" + } +] diff --git a/bot/resources/easter/egghead_questions.json b/bot/resources/easter/egghead_questions.json index e4e21ebe..5535f8e0 100644 --- a/bot/resources/easter/egghead_questions.json +++ b/bot/resources/easter/egghead_questions.json @@ -47,7 +47,7 @@ "1999", "1970" ], - "correct_answer": 2 + "correct_answer": 3 }, { "question": "Who is considered to be the founder of Earth Day?", @@ -178,4 +178,4 @@ ], "correct_answer": 0 } -]
\ No newline at end of file +] diff --git a/bot/resources/evergreen/game_recs/chrono_trigger.json b/bot/resources/evergreen/game_recs/chrono_trigger.json new file mode 100644 index 00000000..219c1e78 --- /dev/null +++ b/bot/resources/evergreen/game_recs/chrono_trigger.json @@ -0,0 +1,7 @@ +{ + "title": "Chrono Trigger", + "description": "One of the best games of all time. A brilliant story involving time-travel with loveable characters. It has a brilliant score by Yasonuri Mitsuda and artwork by Akira Toriyama. With over 20 endings and New Game+, there is a huge amount of replay value here.", + "link": "https://rawg.io/games/chrono-trigger-1995", + "image": "https://vignette.wikia.nocookie.net/chrono/images/2/24/Chrono_Trigger_cover.jpg", + "author": "352635617709916161" +}
\ No newline at end of file diff --git a/bot/resources/evergreen/game_recs/digimon_world.json b/bot/resources/evergreen/game_recs/digimon_world.json new file mode 100644 index 00000000..a2820f8e --- /dev/null +++ b/bot/resources/evergreen/game_recs/digimon_world.json @@ -0,0 +1,7 @@ +{ + "title": "Digimon World", + "description": "A great mix of town-building and pet-raising set in the Digimon universe. With plenty of Digimon to raise and recruit to the village, this charming game will keep you occupied for a long time.", + "image": "https://www.mobygames.com/images/covers/l/437308-digimon-world-playstation-front-cover.jpg", + "link": "https://rawg.io/games/digimon-world", + "author": "352635617709916161" +}
\ No newline at end of file diff --git a/bot/resources/evergreen/game_recs/doom_2.json b/bot/resources/evergreen/game_recs/doom_2.json new file mode 100644 index 00000000..e228e2b1 --- /dev/null +++ b/bot/resources/evergreen/game_recs/doom_2.json @@ -0,0 +1,7 @@ +{ + "title": "Doom II", + "description": "Doom 2 was one of the first FPS games that I truly enjoyed. It offered awesome weapons, terrifying demons to kill, and a great atmosphere to do it in.", + "image": "https://upload.wikimedia.org/wikipedia/en/thumb/2/29/Doom_II_-_Hell_on_Earth_Coverart.png/220px-Doom_II_-_Hell_on_Earth_Coverart.png", + "link": "https://rawg.io/games/doom-ii", + "author": "352635617709916161" +}
\ No newline at end of file diff --git a/bot/resources/evergreen/game_recs/skyrim.json b/bot/resources/evergreen/game_recs/skyrim.json new file mode 100644 index 00000000..09f93563 --- /dev/null +++ b/bot/resources/evergreen/game_recs/skyrim.json @@ -0,0 +1,7 @@ +{ + "title": "Elder Scrolls V: Skyrim", + "description": "The latest mainline Elder Scrolls game offered a fantastic role-playing experience with untethered freedom and a great story. Offering vast mod support, the game has endless customization and replay value.", + "image": "https://upload.wikimedia.org/wikipedia/en/1/15/The_Elder_Scrolls_V_Skyrim_cover.png", + "link": "https://rawg.io/games/the-elder-scrolls-v-skyrim", + "author": "352635617709916161" +}
\ No newline at end of file diff --git a/bot/resources/evergreen/speedrun_links.json b/bot/resources/evergreen/speedrun_links.json new file mode 100644 index 00000000..acb5746a --- /dev/null +++ b/bot/resources/evergreen/speedrun_links.json @@ -0,0 +1,18 @@ + [ + "https://www.youtube.com/watch?v=jNE28SDXdyQ", + "https://www.youtube.com/watch?v=iI8Giq7zQDk", + "https://www.youtube.com/watch?v=VqNnkqQgFbc", + "https://www.youtube.com/watch?v=Gum4GI2Jr0s", + "https://www.youtube.com/watch?v=5YHjHzHJKkU", + "https://www.youtube.com/watch?v=X0pJSTy4tJI", + "https://www.youtube.com/watch?v=aVFq0H6D6_M", + "https://www.youtube.com/watch?v=1O6LuJbEbSI", + "https://www.youtube.com/watch?v=Bgh30BiWG58", + "https://www.youtube.com/watch?v=wwvgAAvhxM8", + "https://www.youtube.com/watch?v=0TWQr0_fi80", + "https://www.youtube.com/watch?v=hatqZby-0to", + "https://www.youtube.com/watch?v=tmnMq2Hw72w", + "https://www.youtube.com/watch?v=UTkyeTCAucA", + "https://www.youtube.com/watch?v=67kQ3l-1qMs", + "https://www.youtube.com/watch?v=14wqBA5Q1yc" +] diff --git a/bot/resources/halloween/github_links.json b/bot/resources/halloween/github_links.json index e69de29b..0967ef42 100644 --- a/bot/resources/halloween/github_links.json +++ b/bot/resources/halloween/github_links.json @@ -0,0 +1 @@ +{} diff --git a/bot/resources/persist/egg_hunt.sqlite b/bot/resources/persist/egg_hunt.sqlite Binary files differdeleted file mode 100644 index 6a7ae32d..00000000 --- a/bot/resources/persist/egg_hunt.sqlite +++ /dev/null diff --git a/bot/resources/pride/anthems.json b/bot/resources/pride/anthems.json new file mode 100644 index 00000000..d80d7558 --- /dev/null +++ b/bot/resources/pride/anthems.json @@ -0,0 +1,455 @@ +[ + { + "title": "Bi", + "artist": "Alicia Champion", + "url": "https://www.youtube.com/watch?v=HekhW9STg58", + "genre": [ + "pop" + ] + }, + { + "title": "Queer Kidz", + "artist": "Ashby and the Oceanns", + "url": "https://www.youtube.com/watch?v=tSdCciMIlO8", + "genre": [ + "folk" + ] + }, + { + "title": "I Like Boys", + "artist": "Todrick Hall", + "url": "https://www.youtube.com/watch?v=RIbGksV3YBY", + "genre": [ + "dance", + "electronic" + ] + }, + { + "title": "It Girl", + "artist": "Mister Wallace", + "url": "https://www.youtube.com/watch?v=NEnmporrBuo", + "genre": [ + "dance", + "electronic" + ] + }, + { + "title": "Gay Sex", + "artist": "Be Steadwell", + "url": "https://www.youtube.com/watch?v=XnbQu_pzf8o", + "genre": [ + "pop" + ] + }, + { + "title": "Pynk", + "artist": "Janelle Monae", + "url": "https://www.youtube.com/watch?v=PaYvlVR_BEc", + "genre": [ + "rnb", + "rhythm and blues", + "r&b", + "soul" + ] + }, + { + "title": "I don't do boys", + "artist": "Elektra", + "url": "https://www.youtube.com/watch?v=MxAvsYrHOmI", + "genre": [ + "rock", + "pop" + ] + }, + { + "title": "Girls Like Girls", + "artist": "Hayley Kiyoko", + "url": "https://www.youtube.com/watch?v=I0MT8SwNa_U", + "genre": [ + "pop", + "electropop" + ] + }, + { + "title": "Girls/Girls/Boys", + "artist": "Panic! at the Disco", + "url": "https://www.youtube.com/watch?v=Yk8jV7r6VMk", + "genre": [ + "alt", + "alternative", + "indie", + "new-wave", + "electropop", + "pop", + "rock" + ] + }, + { + "title": "I Will Survive", + "artist": "Gloria Gaynor", + "url": "https://www.youtube.com/watch?v=gYkACVDFmeg", + "genre": [ + "jazz", + "disco", + "rnb", + "r&b", + "rhythm and blues", + "soul", + "dance", + "electronic", + "pop" + ] + }, + { + "title": "Born This Way", + "artist": "Lady Gaga", + "url": "https://www.youtube.com/watch?v=wV1FrqwZyKw", + "genre": [ + "pop", + "electropop" + ] + }, + { + "title": "Raise Your Glass", + "artist": "P!nk", + "url": "https://www.youtube.com/watch?v=XjVNlG5cZyQ", + "genre": [ + "pop", + "rock", + "pop-rock" + ] + }, + { + "title": "We R Who We R", + "artist": "Ke$ha", + "url": "https://www.youtube.com/watch?v=mXvmSaE0JXA", + "genre": [ + "pop", + "dance-pop" + ] + }, + { + "title": "I'm Coming Out", + "artist": "Diana Ross", + "url": "https://www.youtube.com/watch?v=zbYcte4ZEgQ", + "genre": [ + "disco", + "funk", + "soul" + ] + }, + { + "title": "She Keeps Me Warm", + "artist": "Mary Lambert", + "url": "https://www.youtube.com/watch?v=NhqH-r7Xj0E", + "genre": [ + "pop" + ] + }, + { + "title": "June", + "artist": "Florence + The Machine", + "url": "https://www.youtube.com/watch?v=Sosmd6RjeA0", + "genre": [ + "alt", + "indie", + "alternative" + ] + }, + { + "title": "Do I Wanna Know", + "artist": "MS MR", + "url": "https://youtu.be/0DCDf1O4e1Q", + "genre": [ + "indie", + "indie-pop" + ] + }, + { + "title": "Delilah", + "artist": "Florence + The Machine", + "url": "https://www.youtube.com/watch?v=zZr5Tid3Qw4", + "genre": [ + "alt", + "alternative", + "indie" + ] + }, + { + "title": "Queen", + "artist": "Janelle Monae", + "url": "https://www.youtube.com/watch?v=tEddixS-UoU", + "genre": [ + "neo-soul" + ] + }, + { + "title": "Aesthetic", + "artist": "Hi, I'm Case", + "url": "https://www.youtube.com/watch?v=cgq-XaSC1aY", + "genre": [ + "pop", + "pop-rock" + ] + }, + { + "title": "Break Free", + "artist": "Queen", + "url": "https://www.youtube.com/watch?v=f4Mc-NYPHaQ", + "genre": [ + "rock", + "synth-pop" + ] + }, + { + "title": "LGBT", + "artist": "CupcakKe", + "url": "https://www.youtube.com/watch?v=U_OArkw5yeQ", + "genre": [ + "hip-hop", + "rap" + ] + }, + { + "title": "Rainbow Connections", + "artist": "Garfunkel and Oates", + "url": "https://www.youtube.com/watch?v=MneRtx7x2vs", + "genre": [ + "folk" + ] + }, + { + "title": "Proud", + "artist": "Heather Small", + "url": "https://www.youtube.com/watch?v=LEoxGJ79PMs", + "genre": [ + "dance-pop", + "r&b", + "rhythm and blues", + "rnb" + ] + }, + { + "title": "LGBT", + "artist": "Lowell", + "url": "https://www.youtube.com/watch?v=QgE6nZmTGLw", + "genre": [ + "alternative", + "indie", + "alt", + "pop" + ] + }, + { + "title": "Kiss the Boy", + "artist": "Keiynan Lonsdale", + "url": "https://www.youtube.com/watch?v=bXzLZ7QQnpQ", + "genre": [ + "pop" + ] + }, + { + "title": "Boys Aside", + "artist": "Sofya Wang", + "url": "https://www.youtube.com/watch?v=NlAW7l6dmeA", + "genre": [ + "pop" + ] + }, + { + "title": "Everyone is Gay", + "artist": "A Great Big World", + "url": "https://www.youtube.com/watch?v=0VG1bj4Lj1Q", + "genre": [ + "pop" + ] + }, + { + "title": "The Queer Gospel", + "artist": "Erin McKeown", + "url": "https://www.youtube.com/watch?v=2vvOEoT-q_o", + "genre": [ + "christian", + "gospel" + ] + }, + { + "title": "Girls", + "artist": "Girl in Red", + "url": "https://www.youtube.com/watch?v=_BMBDY01kPk", + "genre": [ + "alternative", + "indie", + "alt" + ] + }, + { + "title": "Crazy World", + "artist": "MNEK", + "url": "https://www.youtube.com/watch?v=YBwzTgNL-zw", + "genre": [ + "pop" + ] + }, + { + "title": "Pride", + "artist": "Grace Petrie", + "url": "https://www.youtube.com/watch?v=y5rMrPJzFGs", + "genre": [ + "alt", + "alternative", + "indie" + ] + }, + { + "title": "Good Guys", + "artist": "MIKA", + "url": "https://www.youtube.com/watch?v=VZQ_9eebry0", + "genre": [ + "pop" + ] + }, + { + "title": "Gender is Boring", + "artist": "She/Her/Hers", + "url": "https://www.youtube.com/watch?v=glJW2vlBAQg", + "genre": [ + "punk" + ] + }, + { + "title": "GUY.exe", + "artist": "Superfruit", + "url": "https://www.youtube.com/watch?v=r2Kh_XMIDPU", + "genre": [ + "pop" + ] + }, + { + "title": "That's So Gay", + "artist": "Pansy Division", + "url": "https://www.youtube.com/watch?v=xlpcyeadaTk", + "genre": [ + "rock" + ] + }, + { + "title": "Strangers", + "artist": "Halsey", + "url": "https://www.youtube.com/watch?v=RVd_71ZdRd4", + "genre": [ + "pop", + "alt", + "alternative", + "indie", + "electropop" + ] + }, + { + "title": "LGBTQIA", + "artist": "Matt Fishel", + "url": "https://www.youtube.com/watch?v=KQq9f5hNOxE", + "genre": [ + "rock" + ] + }, + { + "title": "Tell Me a Story", + "artist": "Skylar Kergil", + "url": "https://www.youtube.com/watch?v=nbQDTE2s3dI", + "genre": [ + "folk" + ] + }, + { + "title": "Trans Is Love", + "artist": "Marissa Kay", + "url": "https://www.youtube.com/watch?v=-5f_1H0RD1I", + "genre": [ + "alt", + "alternative", + "indie", + "alt-country", + "alt-folk", + "indie-rock", + "new-southern-rock" + ] + }, + { + "title": "You Can't Tell Me", + "artist": "Jake Edwards", + "url": "https://www.youtube.com/watch?v=CwqDG5269Ak", + "genre": [ + "pop" + ] + }, + { + "title": "Closet Femme", + "artist": "Kate Reid", + "url": "https://www.youtube.com/watch?v=va-nqcNxP_k", + "genre": [ + "folk" + ] + }, + { + "title": "Let's Have a Kiki", + "artist": "Scissor Sisters", + "url": "https://www.youtube.com/watch?v=eGCD4xb-Tr8", + "genre": [ + "electropop", + "pop" + ] + }, + { + "title": "Gimme Gimme Gimme", + "artist": "ABBA", + "url": "https://www.youtube.com/watch?v=JWay7CDEyAI", + "genre": [ + "disco" + ] + }, + { + "title": "Dancing Queen", + "artist": "ABBA", + "url": "https://www.youtube.com/watch?v=xFrGuyw1V8s", + "genre": [ + "disco", + "europop", + "euero-disco" + ] + }, + { + "title": "City Grrl", + "artist": "CSS", + "url": "https://www.youtube.com/watch?v=duOA3FgpZqY", + "genre": [ + "electropop" + ] + }, + { + "title": "Blame it on the Girls", + "artist": "MIKA", + "url": "https://www.youtube.com/watch?v=iF_w7oaBHNo", + "genre": [ + "pop", + "pop-rock" + ] + }, + { + "title": "Bye Bye Bye", + "artist": "*NSYNC", + "url": "https://www.youtube.com/watch?v=Eo-KmOd3i7s", + "genre": [ + "pop", + "europop" + ] + }, + { + "title": "Gettin Bi", + "artist": "Crazy Ex-Girlfriend", + "url": "https://www.youtube.com/watch?v=5e7844P77Is", + "genre": [ + "pop" + ] + } +]
\ No newline at end of file diff --git a/bot/resources/pride/flags/agender.png b/bot/resources/pride/flags/agender.png Binary files differnew file mode 100644 index 00000000..8a09e5fa --- /dev/null +++ b/bot/resources/pride/flags/agender.png diff --git a/bot/resources/pride/flags/androgyne.png b/bot/resources/pride/flags/androgyne.png Binary files differnew file mode 100644 index 00000000..da40ec01 --- /dev/null +++ b/bot/resources/pride/flags/androgyne.png diff --git a/bot/resources/pride/flags/aromantic.png b/bot/resources/pride/flags/aromantic.png Binary files differnew file mode 100644 index 00000000..7c42a200 --- /dev/null +++ b/bot/resources/pride/flags/aromantic.png diff --git a/bot/resources/pride/flags/asexual.png b/bot/resources/pride/flags/asexual.png Binary files differnew file mode 100644 index 00000000..c339b239 --- /dev/null +++ b/bot/resources/pride/flags/asexual.png diff --git a/bot/resources/pride/flags/bigender.png b/bot/resources/pride/flags/bigender.png Binary files differnew file mode 100644 index 00000000..9864f9bb --- /dev/null +++ b/bot/resources/pride/flags/bigender.png diff --git a/bot/resources/pride/flags/bisexual.png b/bot/resources/pride/flags/bisexual.png Binary files differnew file mode 100644 index 00000000..2479bc8e --- /dev/null +++ b/bot/resources/pride/flags/bisexual.png diff --git a/bot/resources/pride/flags/demiboy.png b/bot/resources/pride/flags/demiboy.png Binary files differnew file mode 100644 index 00000000..95f68717 --- /dev/null +++ b/bot/resources/pride/flags/demiboy.png diff --git a/bot/resources/pride/flags/demigirl.png b/bot/resources/pride/flags/demigirl.png Binary files differnew file mode 100644 index 00000000..6df49bce --- /dev/null +++ b/bot/resources/pride/flags/demigirl.png diff --git a/bot/resources/pride/flags/demisexual.png b/bot/resources/pride/flags/demisexual.png Binary files differnew file mode 100644 index 00000000..5339330e --- /dev/null +++ b/bot/resources/pride/flags/demisexual.png diff --git a/bot/resources/pride/flags/gay.png b/bot/resources/pride/flags/gay.png Binary files differnew file mode 100644 index 00000000..5a454ca3 --- /dev/null +++ b/bot/resources/pride/flags/gay.png diff --git a/bot/resources/pride/flags/genderfluid.png b/bot/resources/pride/flags/genderfluid.png Binary files differnew file mode 100644 index 00000000..ac22f093 --- /dev/null +++ b/bot/resources/pride/flags/genderfluid.png diff --git a/bot/resources/pride/flags/genderqueer.png b/bot/resources/pride/flags/genderqueer.png Binary files differnew file mode 100644 index 00000000..4652c7e6 --- /dev/null +++ b/bot/resources/pride/flags/genderqueer.png diff --git a/bot/resources/pride/flags/intersex.png b/bot/resources/pride/flags/intersex.png Binary files differnew file mode 100644 index 00000000..c58a3bfe --- /dev/null +++ b/bot/resources/pride/flags/intersex.png diff --git a/bot/resources/pride/flags/lesbian.png b/bot/resources/pride/flags/lesbian.png Binary files differnew file mode 100644 index 00000000..824b9a89 --- /dev/null +++ b/bot/resources/pride/flags/lesbian.png diff --git a/bot/resources/pride/flags/nonbinary.png b/bot/resources/pride/flags/nonbinary.png Binary files differnew file mode 100644 index 00000000..ee3c50e2 --- /dev/null +++ b/bot/resources/pride/flags/nonbinary.png diff --git a/bot/resources/pride/flags/omnisexual.png b/bot/resources/pride/flags/omnisexual.png Binary files differnew file mode 100644 index 00000000..2527051d --- /dev/null +++ b/bot/resources/pride/flags/omnisexual.png diff --git a/bot/resources/pride/flags/pangender.png b/bot/resources/pride/flags/pangender.png Binary files differnew file mode 100644 index 00000000..38004654 --- /dev/null +++ b/bot/resources/pride/flags/pangender.png diff --git a/bot/resources/pride/flags/pansexual.png b/bot/resources/pride/flags/pansexual.png Binary files differnew file mode 100644 index 00000000..0e56b534 --- /dev/null +++ b/bot/resources/pride/flags/pansexual.png diff --git a/bot/resources/pride/flags/polyamory.png b/bot/resources/pride/flags/polyamory.png Binary files differnew file mode 100644 index 00000000..b41f061f --- /dev/null +++ b/bot/resources/pride/flags/polyamory.png diff --git a/bot/resources/pride/flags/polysexual.png b/bot/resources/pride/flags/polysexual.png Binary files differnew file mode 100644 index 00000000..b2aba22c --- /dev/null +++ b/bot/resources/pride/flags/polysexual.png diff --git a/bot/resources/pride/flags/transgender.png b/bot/resources/pride/flags/transgender.png Binary files differnew file mode 100644 index 00000000..73f01043 --- /dev/null +++ b/bot/resources/pride/flags/transgender.png diff --git a/bot/resources/pride/flags/trigender.png b/bot/resources/pride/flags/trigender.png Binary files differnew file mode 100644 index 00000000..06ff0f7c --- /dev/null +++ b/bot/resources/pride/flags/trigender.png diff --git a/bot/seasons/__init__.py b/bot/seasons/__init__.py index 1512fae2..7faf9164 100644 --- a/bot/seasons/__init__.py +++ b/bot/seasons/__init__.py @@ -1,5 +1,7 @@ import logging +from discord.ext import commands + from bot.seasons.season import SeasonBase, SeasonManager, get_season __all__ = ("SeasonBase", "get_season") @@ -7,6 +9,6 @@ __all__ = ("SeasonBase", "get_season") log = logging.getLogger(__name__) -def setup(bot): +def setup(bot: commands.Bot) -> None: bot.add_cog(SeasonManager(bot)) log.info("SeasonManager cog loaded") diff --git a/bot/seasons/christmas/__init__.py b/bot/seasons/christmas/__init__.py index f0a7c2c6..ae93800e 100644 --- a/bot/seasons/christmas/__init__.py +++ b/bot/seasons/christmas/__init__.py @@ -18,7 +18,9 @@ class Christmas(SeasonBase): greeting = "Happy Holidays!" start_date = "01/12" - end_date = "31/12" + end_date = "01/01" colour = Colours.dark_green - icon = "/logos/logo_seasonal/christmas/festive.png" + icon = ( + "/logos/logo_seasonal/christmas/festive.png", + ) diff --git a/bot/seasons/christmas/adventofcode.py b/bot/seasons/christmas/adventofcode.py index 440484b4..513c1020 100644 --- a/bot/seasons/christmas/adventofcode.py +++ b/bot/seasons/christmas/adventofcode.py @@ -14,6 +14,7 @@ from discord.ext import commands from pytz import timezone from bot.constants import AdventOfCode as AocConfig, Channels, Colours, Emojis, Tokens +from bot.decorators import override_in_channel log = logging.getLogger(__name__) @@ -26,14 +27,12 @@ COUNTDOWN_STEP = 60 * 5 def is_in_advent() -> bool: """Utility function to check if we are between December 1st and December 25th.""" - # Run the code from the 1st to the 24th return datetime.now(EST).day in range(1, 25) and datetime.now(EST).month == 12 def time_left_to_aoc_midnight() -> Tuple[datetime, timedelta]: """Calculates the amount of time left until midnight in UTC-5 (Advent of Code maintainer timezone).""" - # Change all time properties back to 00:00 todays_midnight = datetime.now(EST).replace(microsecond=0, second=0, @@ -47,9 +46,8 @@ def time_left_to_aoc_midnight() -> Tuple[datetime, timedelta]: return tomorrow, tomorrow - datetime.now(EST) -async def countdown_status(bot: commands.Bot): +async def countdown_status(bot: commands.Bot) -> None: """Set the playing status of the bot to the minutes & hours left until the next day's challenge.""" - while is_in_advent(): _, time_left = time_left_to_aoc_midnight() @@ -75,14 +73,13 @@ async def countdown_status(bot: commands.Bot): await asyncio.sleep(delay) -async def day_countdown(bot: commands.Bot): +async def day_countdown(bot: commands.Bot) -> None: """ - Calculate the number of seconds left until the next day of advent. + Calculate the number of seconds left until the next day of Advent. Once we have calculated this we should then sleep that number and when the time is reached, ping the Advent of Code role notifying them that the new challenge is ready. """ - while is_in_advent(): tomorrow, time_left = time_left_to_aoc_midnight() @@ -104,7 +101,7 @@ async def day_countdown(bot: commands.Bot): class AdventOfCode(commands.Cog): - """Advent of Code festivities! Ho Ho Ho.""" + """Advent of Code festivities! Ho Ho Ho!""" def __init__(self, bot: commands.Bot): self.bot = bot @@ -129,9 +126,9 @@ class AdventOfCode(commands.Cog): self.status_task = asyncio.ensure_future(self.bot.loop.create_task(status_coro)) @commands.group(name="adventofcode", aliases=("aoc",), invoke_without_command=True) - async def adventofcode_group(self, ctx: commands.Context): + @override_in_channel() + async def adventofcode_group(self, ctx: commands.Context) -> None: """All of the Advent of Code commands.""" - await ctx.send_help(ctx.command) @adventofcode_group.command( @@ -139,9 +136,8 @@ class AdventOfCode(commands.Cog): aliases=("sub", "notifications", "notify", "notifs"), brief="Notifications for new days" ) - async def aoc_subscribe(self, ctx: commands.Context): + async def aoc_subscribe(self, ctx: commands.Context) -> None: """Assign the role for notifications about new days being ready.""" - role = ctx.guild.get_role(AocConfig.role_id) unsubscribe_command = f"{ctx.prefix}{ctx.command.root_parent} unsubscribe" @@ -154,9 +150,8 @@ class AdventOfCode(commands.Cog): f"If you don't want them any more, run `{unsubscribe_command}` instead.") @adventofcode_group.command(name="unsubscribe", aliases=("unsub",), brief="Notifications for new days") - async def aoc_unsubscribe(self, ctx: commands.Context): + async def aoc_unsubscribe(self, ctx: commands.Context) -> None: """Remove the role for notifications about new days being ready.""" - role = ctx.guild.get_role(AocConfig.role_id) if role in ctx.author.roles: @@ -166,9 +161,8 @@ class AdventOfCode(commands.Cog): await ctx.send("Hey, you don't even get any notifications about new Advent of Code tasks currently anyway.") @adventofcode_group.command(name="countdown", aliases=("count", "c"), brief="Return time left until next day") - async def aoc_countdown(self, ctx: commands.Context): + async def aoc_countdown(self, ctx: commands.Context) -> None: """Return time left until next day.""" - if not is_in_advent(): datetime_now = datetime.now(EST) december_first = datetime(datetime_now.year + 1, 12, 1, tzinfo=EST) @@ -184,15 +178,13 @@ class AdventOfCode(commands.Cog): await ctx.send(f"There are {hours} hours and {minutes} minutes left until day {tomorrow.day}.") @adventofcode_group.command(name="about", aliases=("ab", "info"), brief="Learn about Advent of Code") - async def about_aoc(self, ctx: commands.Context): + async def about_aoc(self, ctx: commands.Context) -> None: """Respond with an explanation of all things Advent of Code.""" - await ctx.send("", embed=self.cached_about_aoc) @adventofcode_group.command(name="join", aliases=("j",), brief="Learn how to join PyDis' private AoC leaderboard") - async def join_leaderboard(self, ctx: commands.Context): + async def join_leaderboard(self, ctx: commands.Context) -> None: """DM the user the information for joining the PyDis AoC private leaderboard.""" - author = ctx.message.author log.info(f"{author.name} ({author.id}) has requested the PyDis AoC leaderboard code") @@ -211,7 +203,7 @@ class AdventOfCode(commands.Cog): aliases=("board", "lb"), brief="Get a snapshot of the PyDis private AoC leaderboard", ) - async def aoc_leaderboard(self, ctx: commands.Context, number_of_people_to_display: int = 10): + async def aoc_leaderboard(self, ctx: commands.Context, number_of_people_to_display: int = 10) -> None: """ Pull the top number_of_people_to_display members from the PyDis leaderboard and post an embed. @@ -219,7 +211,6 @@ class AdventOfCode(commands.Cog): Advent of Code section of the bot constants. number_of_people_to_display values greater than this limit will default to this maximum and provide feedback to the user. """ - async with ctx.typing(): await self._check_leaderboard_cache(ctx) @@ -253,13 +244,12 @@ class AdventOfCode(commands.Cog): aliases=("dailystats", "ds"), brief="Get daily statistics for the PyDis private leaderboard" ) - async def private_leaderboard_daily_stats(self, ctx: commands.Context): + async def private_leaderboard_daily_stats(self, ctx: commands.Context) -> None: """ Respond with a table of the daily completion statistics for the PyDis private leaderboard. Embed will display the total members and the number of users who have completed each day's puzzle """ - async with ctx.typing(): await self._check_leaderboard_cache(ctx) @@ -297,7 +287,7 @@ class AdventOfCode(commands.Cog): aliases=("globalboard", "gb"), brief="Get a snapshot of the global AoC leaderboard", ) - async def global_leaderboard(self, ctx: commands.Context, number_of_people_to_display: int = 10): + async def global_leaderboard(self, ctx: commands.Context, number_of_people_to_display: int = 10) -> None: """ Pull the top number_of_people_to_display members from the global AoC leaderboard and post an embed. @@ -305,7 +295,6 @@ class AdventOfCode(commands.Cog): Advent of Code section of the bot constants. number_of_people_to_display values greater than this limit will default to this maximum and provide feedback to the user. """ - async with ctx.typing(): await self._check_leaderboard_cache(ctx, global_board=True) @@ -330,13 +319,12 @@ class AdventOfCode(commands.Cog): embed=aoc_embed, ) - async def _check_leaderboard_cache(self, ctx, global_board: bool = False): + async def _check_leaderboard_cache(self, ctx: commands.Context, global_board: bool = False) -> None: """ Check age of current leaderboard & pull a new one if the board is too old. global_board is a boolean to toggle between the global board and the Pydis private board """ - # Toggle between global & private leaderboards if global_board: log.debug("Checking global leaderboard cache") @@ -371,7 +359,7 @@ class AdventOfCode(commands.Cog): ) async def _check_n_entries(self, ctx: commands.Context, number_of_people_to_display: int) -> int: - # Check for n > max_entries and n <= 0 + """Check for n > max_entries and n <= 0.""" max_entries = AocConfig.leaderboard_max_displayed_members author = ctx.message.author if not 0 <= number_of_people_to_display <= max_entries: @@ -390,7 +378,6 @@ class AdventOfCode(commands.Cog): def _build_about_embed(self) -> discord.Embed: """Build and return the informational "About AoC" embed from the resources file.""" - with self.about_aoc_filepath.open("r") as f: embed_fields = json.load(f) @@ -403,9 +390,8 @@ class AdventOfCode(commands.Cog): return about_embed - async def _boardgetter(self, global_board: bool): + async def _boardgetter(self, global_board: bool) -> None: """Invoke the proper leaderboard getter based on the global_board boolean.""" - if global_board: self.cached_global_leaderboard = await AocGlobalLeaderboard.from_url() else: @@ -426,7 +412,6 @@ class AocMember: def __repr__(self): """Generate a user-friendly representation of the AocMember & their score.""" - return f"<{self.name} ({self.aoc_id}): {self.local_score}>" @classmethod @@ -440,7 +425,6 @@ class AocMember: Returns an AocMember object """ - return cls( name=injson["name"] if injson["name"] else "Anonymous User", aoc_id=int(injson["id"]), @@ -462,7 +446,6 @@ class AocMember: Returns a list of 25 lists, where each nested list contains a pair of booleans representing the code challenge completion status for that day """ - # Basic input validation if not isinstance(injson, dict): raise ValueError @@ -487,7 +470,6 @@ class AocMember: @staticmethod def _completions_from_starboard(starboard: list) -> tuple: """Return days completed, as a (1 star, 2 star) tuple, from starboard.""" - completions = [0, 0] for day in starboard: if day[0]: @@ -515,7 +497,6 @@ class AocPrivateLeaderboard: If n is not specified, default to the top 10 """ - return self.members[:n] def calculate_daily_completion(self) -> List[tuple]: @@ -525,7 +506,6 @@ class AocPrivateLeaderboard: Return a list of tuples for each day containing the number of users who completed each part of the challenge """ - daily_member_completions = [] for day in range(25): one_star_count = 0 @@ -550,7 +530,6 @@ class AocPrivateLeaderboard: If no year is input, year defaults to the current year """ - api_url = f"https://adventofcode.com/{year}/leaderboard/private/view/{leaderboard_id}.json" log.debug("Querying Advent of Code Private Leaderboard API") @@ -567,7 +546,6 @@ class AocPrivateLeaderboard: @classmethod def from_json(cls, injson: dict) -> "AocPrivateLeaderboard": """Generate an AocPrivateLeaderboard object from AoC's private leaderboard API JSON.""" - return cls( members=cls._sorted_members(injson["members"]), owner_id=injson["owner_id"], event_year=injson["event"] ) @@ -575,7 +553,6 @@ class AocPrivateLeaderboard: @classmethod async def from_url(cls) -> "AocPrivateLeaderboard": """Helper wrapping of AocPrivateLeaderboard.json_from_url and AocPrivateLeaderboard.from_json.""" - api_json = await cls.json_from_url() return cls.from_json(api_json) @@ -586,7 +563,6 @@ class AocPrivateLeaderboard: Output list is sorted based on the AocMember.local_score """ - members = [AocMember.member_from_json(injson[member]) for member in injson] members.sort(key=lambda x: x.local_score, reverse=True) @@ -599,7 +575,6 @@ class AocPrivateLeaderboard: Returns a string to be used as the content of the bot's leaderboard response """ - stargroup = f"{Emojis.star}, {Emojis.star*2}" header = f"{' '*3}{'Score'} {'Name':^25} {stargroup:^7}\n{'-'*44}" table = "" @@ -632,7 +607,6 @@ class AocGlobalLeaderboard: If n is not specified, default to the top 10 """ - return self.members[:n] @classmethod @@ -642,7 +616,6 @@ class AocGlobalLeaderboard: Because there is no API for this, web scraping needs to be used """ - aoc_url = f"https://adventofcode.com/{AocConfig.year}/leaderboard" async with aiohttp.ClientSession(headers=AOC_REQUEST_HEADER) as session: @@ -688,7 +661,6 @@ class AocGlobalLeaderboard: Returns a string to be used as the content of the bot's leaderboard response """ - header = f"{' '*4}{'Score'} {'Name':^25}\n{'-'*36}" table = "" for member in members_to_print: @@ -706,12 +678,10 @@ class AocGlobalLeaderboard: def _error_embed_helper(title: str, description: str) -> discord.Embed: """Return a red-colored Embed with the given title and description.""" - return discord.Embed(title=title, description=description, colour=discord.Colour.red()) def setup(bot: commands.Bot) -> None: """Advent of Code Cog load.""" - bot.add_cog(AdventOfCode(bot)) log.info("AdventOfCode cog loaded") diff --git a/bot/seasons/christmas/hanukkah_embed.py b/bot/seasons/christmas/hanukkah_embed.py index 652a1f35..aaa02b27 100644 --- a/bot/seasons/christmas/hanukkah_embed.py +++ b/bot/seasons/christmas/hanukkah_embed.py @@ -1,5 +1,6 @@ import datetime import logging +from typing import List from discord import Embed from discord.ext import commands @@ -13,7 +14,7 @@ log = logging.getLogger(__name__) class HanukkahEmbed(commands.Cog): """A cog that returns information about Hanukkah festival.""" - def __init__(self, bot): + def __init__(self, bot: commands.Bot): self.bot = bot self.url = ("https://www.hebcal.com/hebcal/?v=1&cfg=json&maj=on&min=on&mod=on&nx=on&" "year=now&month=x&ss=on&mf=on&c=on&geo=geoname&geonameid=3448439&m=50&s=on") @@ -21,7 +22,7 @@ class HanukkahEmbed(commands.Cog): self.hanukkah_months = [] self.hanukkah_years = [] - async def get_hanukkah_dates(self): + async def get_hanukkah_dates(self) -> List[str]: """Gets the dates for hanukkah festival.""" hanukkah_dates = [] async with self.bot.http_session.get(self.url) as response: @@ -34,7 +35,7 @@ class HanukkahEmbed(commands.Cog): return hanukkah_dates @commands.command(name='hanukkah', aliases=['chanukah']) - async def hanukkah_festival(self, ctx): + async def hanukkah_festival(self, ctx: commands.Context) -> None: """Tells you about the Hanukkah Festivaltime of festival, festival day, etc).""" hanukkah_dates = await self.get_hanukkah_dates() self.hanukkah_dates_split(hanukkah_dates) @@ -98,7 +99,7 @@ class HanukkahEmbed(commands.Cog): await ctx.send(embed=embed) - def hanukkah_dates_split(self, hanukkah_dates): + def hanukkah_dates_split(self, hanukkah_dates: List[str]) -> None: """We are splitting the dates for hanukkah into days, months and years.""" for date in hanukkah_dates: self.hanukkah_days.append(date[8:10]) @@ -106,7 +107,7 @@ class HanukkahEmbed(commands.Cog): self.hanukkah_years.append(date[0:4]) -def setup(bot): +def setup(bot: commands.Bot) -> None: """Cog load.""" bot.add_cog(HanukkahEmbed(bot)) log.info("Hanukkah embed cog loaded") diff --git a/bot/seasons/easter/__init__.py b/bot/seasons/easter/__init__.py index 83d12ead..1d77b6a6 100644 --- a/bot/seasons/easter/__init__.py +++ b/bot/seasons/easter/__init__.py @@ -30,4 +30,6 @@ class Easter(SeasonBase): end_date = "30/04" colour = Colours.pink - icon = "/logos/logo_seasonal/easter/easter.png" + icon = ( + "/logos/logo_seasonal/easter/easter.png", + ) diff --git a/bot/seasons/easter/april_fools_vids.py b/bot/seasons/easter/april_fools_vids.py index 5dae8485..4869f510 100644 --- a/bot/seasons/easter/april_fools_vids.py +++ b/bot/seasons/easter/april_fools_vids.py @@ -9,30 +9,31 @@ log = logging.getLogger(__name__) class AprilFoolVideos(commands.Cog): - """A cog for april fools that gets a random april fools video from youtube.""" - def __init__(self, bot): + """A cog for April Fools' that gets a random April Fools' video from Youtube.""" + + def __init__(self, bot: commands.Bot): self.bot = bot self.yt_vids = self.load_json() self.youtubers = ['google'] # will add more in future @staticmethod - def load_json(): - """A function to load json data.""" - p = Path('bot', 'resources', 'easter', 'april_fools_vids.json') + def load_json() -> dict: + """A function to load JSON data.""" + p = Path('bot/resources/easter/april_fools_vids.json') with p.open() as json_file: all_vids = load(json_file) return all_vids @commands.command(name='fool') - async def aprial_fools(self, ctx): - """Gets a random april fools video from youtube.""" + async def april_fools(self, ctx: commands.Context) -> None: + """Get a random April Fools' video from Youtube.""" random_youtuber = random.choice(self.youtubers) category = self.yt_vids[random_youtuber] random_vid = random.choice(category) await ctx.send(f"Check out this April Fools' video by {random_youtuber}.\n\n{random_vid['link']}") -def setup(bot): - """A function to add the cog.""" +def setup(bot: commands.Bot) -> None: + """April Fools' Cog load.""" bot.add_cog(AprilFoolVideos(bot)) log.info('April Fools videos cog loaded!') diff --git a/bot/seasons/easter/avatar_easterifier.py b/bot/seasons/easter/avatar_easterifier.py index a84e5eb4..e21e35fc 100644 --- a/bot/seasons/easter/avatar_easterifier.py +++ b/bot/seasons/easter/avatar_easterifier.py @@ -2,7 +2,7 @@ import asyncio import logging from io import BytesIO from pathlib import Path -from typing import Union +from typing import Tuple, Union import discord from PIL import Image @@ -21,22 +21,20 @@ COLOURS = [ class AvatarEasterifier(commands.Cog): """Put an Easter spin on your avatar or image!""" - def __init__(self, bot): + def __init__(self, bot: commands.Bot): self.bot = bot @staticmethod - def closest(x): + def closest(x: Tuple[int, int, int]) -> Tuple[int, int, int]: """ Finds the closest easter colour to a given pixel. Returns a merge between the original colour and the closest colour """ - r1, g1, b1 = x - def distance(point): - """Finds the difference between a pastel colour and the original pixel colour""" - + def distance(point: Tuple[int, int, int]) -> Tuple[int, int, int]: + """Finds the difference between a pastel colour and the original pixel colour.""" r2, g2, b2 = point return ((r1 - r2)**2 + (g1 - g2)**2 + (b1 - b2)**2) @@ -45,10 +43,11 @@ class AvatarEasterifier(commands.Cog): r = (r1 + r2) // 2 g = (g1 + g2) // 2 b = (b1 + b2) // 2 + return (r, g, b) @commands.command(pass_context=True, aliases=["easterify"]) - async def avatareasterify(self, ctx, *colours: Union[discord.Colour, str]): + async def avatareasterify(self, ctx: commands.Context, *colours: Union[discord.Colour, str]) -> None: """ This "Easterifies" the user's avatar. @@ -57,8 +56,7 @@ class AvatarEasterifier(commands.Cog): Colours are split by spaces, unless you wrap the colour name in double quotes. Discord colour names, HTML colour names, XKCD colour names and hex values are accepted. """ - - async def send(*args, **kwargs): + async def send(*args, **kwargs) -> str: """ This replaces the original ctx.send. @@ -106,7 +104,7 @@ class AvatarEasterifier(commands.Cog): im.alpha_composite(egg, (im.width - egg.width, (im.height - egg.height)//2)) # Right centre. ctx.send = send_message # Reassigns ctx.send else: - bunny = Image.open(Path("bot", "resources", "easter", "chocolate_bunny.png")) + bunny = Image.open(Path("bot/resources/easter/chocolate_bunny.png")) im.alpha_composite(bunny, (im.width - bunny.width, (im.height - bunny.height)//2)) # Right centre. bufferedio = BytesIO() @@ -125,8 +123,7 @@ class AvatarEasterifier(commands.Cog): await ctx.send(file=file, embed=embed) -def setup(bot): - """Cog load.""" - +def setup(bot: commands.Bot) -> None: + """Avatar Easterifier Cog load.""" bot.add_cog(AvatarEasterifier(bot)) log.info("AvatarEasterifier cog loaded") diff --git a/bot/seasons/easter/bunny_name_generator.py b/bot/seasons/easter/bunny_name_generator.py new file mode 100644 index 00000000..97c467e1 --- /dev/null +++ b/bot/seasons/easter/bunny_name_generator.py @@ -0,0 +1,93 @@ +import json +import logging +import random +import re +from pathlib import Path +from typing import List, Union + +from discord.ext import commands + +log = logging.getLogger(__name__) + +with Path("bot/resources/easter/bunny_names.json").open("r", encoding="utf8") as f: + BUNNY_NAMES = json.load(f) + + +class BunnyNameGenerator(commands.Cog): + """Generate a random bunny name, or bunnify your Discord username!""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + def find_separators(self, displayname: str) -> Union[List[str], None]: + """Check if Discord name contains spaces so we can bunnify an individual word in the name.""" + new_name = re.split(r'[_.\s]', displayname) + if displayname not in new_name: + return new_name + + def find_vowels(self, displayname: str) -> str: + """ + Finds vowels in the user's display name. + + If the Discord name contains a vowel and the letter y, it will match one or more of these patterns. + + Only the most recently matched pattern will apply the changes. + """ + expressions = [ + (r'a.+y', 'patchy'), + (r'e.+y', 'ears'), + (r'i.+y', 'ditsy'), + (r'o.+y', 'oofy'), + (r'u.+y', 'uffy'), + ] + + for exp, vowel_sub in expressions: + new_name = re.sub(exp, vowel_sub, displayname) + if new_name != displayname: + return new_name + + def append_name(self, displayname: str) -> str: + """Adds a suffix to the end of the Discord name.""" + extensions = ['foot', 'ear', 'nose', 'tail'] + suffix = random.choice(extensions) + appended_name = displayname + suffix + + return appended_name + + @commands.command() + async def bunnyname(self, ctx: commands.Context) -> None: + """Picks a random bunny name from a JSON file.""" + await ctx.send(random.choice(BUNNY_NAMES["names"])) + + @commands.command() + async def bunnifyme(self, ctx: commands.Context) -> None: + """Gets your Discord username and bunnifies it.""" + username = ctx.message.author.display_name + + # If name contains spaces or other separators, get the individual words to randomly bunnify + spaces_in_name = self.find_separators(username) + + # If name contains vowels, see if it matches any of the patterns in this function + # If there are matches, the bunnified name is returned. + vowels_in_name = self.find_vowels(username) + + # Default if the checks above return None + unmatched_name = self.append_name(username) + + if spaces_in_name is not None: + replacements = ['Cotton', 'Fluff', 'Floof' 'Bounce', 'Snuffle', 'Nibble', 'Cuddle', 'Velvetpaw', 'Carrot'] + word_to_replace = random.choice(spaces_in_name) + substitute = random.choice(replacements) + bunnified_name = username.replace(word_to_replace, substitute) + elif vowels_in_name is not None: + bunnified_name = vowels_in_name + elif unmatched_name: + bunnified_name = unmatched_name + + await ctx.send(bunnified_name) + + +def setup(bot: commands.Bot) -> None: + """Bunny Name Generator Cog load.""" + bot.add_cog(BunnyNameGenerator(bot)) + log.info("BunnyNameGenerator cog loaded.") diff --git a/bot/seasons/easter/conversationstarters.py b/bot/seasons/easter/conversationstarters.py index b479406b..3f38ae82 100644 --- a/bot/seasons/easter/conversationstarters.py +++ b/bot/seasons/easter/conversationstarters.py @@ -7,25 +7,23 @@ from discord.ext import commands log = logging.getLogger(__name__) -with open(Path('bot', 'resources', 'easter', 'starter.json'), 'r', encoding="utf8") as f: +with open(Path("bot/resources/easter/starter.json"), "r", encoding="utf8") as f: starters = json.load(f) class ConvoStarters(commands.Cog): """Easter conversation topics.""" - def __init__(self, bot): + def __init__(self, bot: commands.Bot): self.bot = bot @commands.command() - async def topic(self, ctx): + async def topic(self, ctx: commands.Context) -> None: """Responds with a random topic to start a conversation.""" - await ctx.send(random.choice(starters['starters'])) -def setup(bot): +def setup(bot: commands.Bot) -> None: """Conversation starters Cog load.""" - bot.add_cog(ConvoStarters(bot)) log.info("ConvoStarters cog loaded") diff --git a/bot/seasons/easter/easter_riddle.py b/bot/seasons/easter/easter_riddle.py new file mode 100644 index 00000000..4b98b204 --- /dev/null +++ b/bot/seasons/easter/easter_riddle.py @@ -0,0 +1,101 @@ +import asyncio +import logging +import random +from json import load +from pathlib import Path + +import discord +from discord.ext import commands + +from bot.constants import Colours + +log = logging.getLogger(__name__) + +with Path("bot/resources/easter/easter_riddle.json").open("r", encoding="utf8") as f: + RIDDLE_QUESTIONS = load(f) + +TIMELIMIT = 10 + + +class EasterRiddle(commands.Cog): + """This cog contains the command for the Easter quiz!""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.winners = [] + self.correct = "" + self.current_channel = None + + @commands.command(aliases=["riddlemethis", "riddleme"]) + async def riddle(self, ctx: commands.Context) -> None: + """ + Gives a random riddle, then provides 2 hints at certain intervals before revealing the answer. + + The duration of the hint interval can be configured by changing the TIMELIMIT constant in this file. + """ + if self.current_channel: + return await ctx.send(f"A riddle is already being solved in {self.current_channel.mention}!") + + self.current_channel = ctx.message.channel + + random_question = random.choice(RIDDLE_QUESTIONS) + question = random_question["question"] + hints = random_question["riddles"] + self.correct = random_question["correct_answer"] + + description = f"You have {TIMELIMIT} seconds before the first hint." + + riddle_embed = discord.Embed(title=question, description=description, colour=Colours.pink) + + await ctx.send(embed=riddle_embed) + await asyncio.sleep(TIMELIMIT) + + hint_embed = discord.Embed( + title=f"Here's a hint: {hints[0]}!", + colour=Colours.pink + ) + + await ctx.send(embed=hint_embed) + await asyncio.sleep(TIMELIMIT) + + hint_embed = discord.Embed( + title=f"Here's a hint: {hints[1]}!", + colour=Colours.pink + ) + + await ctx.send(embed=hint_embed) + await asyncio.sleep(TIMELIMIT) + + if self.winners: + win_list = " ".join(self.winners) + content = f"Well done {win_list} for getting it right!" + else: + content = "Nobody got it right..." + + answer_embed = discord.Embed( + title=f"The answer is: {self.correct}!", + colour=Colours.pink + ) + + await ctx.send(content, embed=answer_embed) + + self.winners = [] + self.current_channel = None + + @commands.Cog.listener() + async def on_message(self, message: discord.Messaged) -> None: + """If a non-bot user enters a correct answer, their username gets added to self.winners.""" + if self.current_channel != message.channel: + return + + if self.bot.user == message.author: + return + + if message.content.lower() == self.correct.lower(): + self.winners.append(message.author.mention) + + +def setup(bot: commands.Bot) -> None: + """Easter Riddle Cog load.""" + bot.add_cog(EasterRiddle(bot)) + log.info("Easter Riddle bot loaded") diff --git a/bot/seasons/easter/egg_decorating.py b/bot/seasons/easter/egg_decorating.py index d283e42a..51f52264 100644 --- a/bot/seasons/easter/egg_decorating.py +++ b/bot/seasons/easter/egg_decorating.py @@ -12,10 +12,10 @@ from discord.ext import commands log = logging.getLogger(__name__) -with open(Path("bot", "resources", "evergreen", "html_colours.json")) as f: +with open(Path("bot/resources/evergreen/html_colours.json")) as f: HTML_COLOURS = json.load(f) -with open(Path("bot", "resources", "evergreen", "xkcd_colours.json")) as f: +with open(Path("bot/resources/evergreen/xkcd_colours.json")) as f: XKCD_COLOURS = json.load(f) COLOURS = [ @@ -31,11 +31,11 @@ IRREPLACEABLE = [ class EggDecorating(commands.Cog): """Decorate some easter eggs!""" - def __init__(self, bot): + def __init__(self, bot: commands.Bot) -> None: self.bot = bot @staticmethod - def replace_invalid(colour: str): + def replace_invalid(colour: str) -> Union[int, None]: """Attempts to match with HTML or XKCD colour names, returning the int value.""" with suppress(KeyError): return int(HTML_COLOURS[colour], 16) @@ -44,14 +44,15 @@ class EggDecorating(commands.Cog): return None @commands.command(aliases=["decorateegg"]) - async def eggdecorate(self, ctx, *colours: Union[discord.Colour, str]): + async def eggdecorate( + self, ctx: commands.Context, *colours: Union[discord.Colour, str] + ) -> Union[Image, discord.Message]: """ Picks a random egg design and decorates it using the given colours. Colours are split by spaces, unless you wrap the colour name in double quotes. Discord colour names, HTML colour names, XKCD colour names and hex values are accepted. """ - if len(colours) < 2: return await ctx.send("You must include at least 2 colours!") @@ -72,13 +73,13 @@ class EggDecorating(commands.Cog): return await ctx.send(f"Sorry, I don't know the colour {invalid[0]}!") async with ctx.typing(): - # expand list to 8 colours + # Expand list to 8 colours colours_n = len(colours) if colours_n < 8: q, r = divmod(8, colours_n) colours = colours * q + colours[:r] num = random.randint(1, 6) - im = Image.open(Path("bot", "resources", "easter", "easter_eggs", f"design{num}.png")) + im = Image.open(Path(f"bot/resources/easter/easter_eggs/design{num}.png")) data = list(im.getdata()) replaceable = {x for x in data if x not in IRREPLACEABLE} @@ -112,8 +113,7 @@ class EggDecorating(commands.Cog): return new_im -def setup(bot): - """Cog load.""" - +def setup(bot: commands.bot) -> None: + """Egg decorating Cog load.""" bot.add_cog(EggDecorating(bot)) log.info("EggDecorating cog loaded.") diff --git a/bot/seasons/easter/egg_facts.py b/bot/seasons/easter/egg_facts.py new file mode 100644 index 00000000..9e6fb1cb --- /dev/null +++ b/bot/seasons/easter/egg_facts.py @@ -0,0 +1,62 @@ +import asyncio +import logging +import random +from json import load +from pathlib import Path + +import discord +from discord.ext import commands + +from bot.constants import Channels +from bot.constants import Colours + + +log = logging.getLogger(__name__) + + +class EasterFacts(commands.Cog): + """ + A cog contains a command that will return an easter egg fact when called. + + It also contains a background task which sends an easter egg fact in the event channel everyday. + """ + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.facts = self.load_json() + + @staticmethod + def load_json() -> dict: + """Load a list of easter egg facts from the resource JSON file.""" + p = Path("bot/resources/easter/easter_egg_facts.json") + with p.open(encoding="utf8") as f: + return load(f) + + 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_chat) + while True: + embed = self.make_embed() + await channel.send(embed=embed) + await asyncio.sleep(24 * 60 * 60) + + @commands.command(name='eggfact', aliases=['fact']) + async def easter_facts(self, ctx: commands.Context) -> None: + """Get easter egg facts.""" + embed = self.make_embed() + await ctx.send(embed=embed) + + def make_embed(self) -> discord.Embed: + """Makes a nice embed for the message to be sent.""" + return discord.Embed( + colour=Colours.soft_red, + title="Easter Egg Fact", + description=random.choice(self.facts) + ) + + +def setup(bot: commands.Bot) -> None: + """Easter Egg facts cog load.""" + bot.loop.create_task(EasterFacts(bot).send_egg_fact_daily()) + bot.add_cog(EasterFacts(bot)) + log.info("EasterFacts cog loaded") diff --git a/bot/seasons/easter/egg_hunt/__init__.py b/bot/seasons/easter/egg_hunt/__init__.py deleted file mode 100644 index 43bda223..00000000 --- a/bot/seasons/easter/egg_hunt/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -import logging - -from .cog import EggHunt - -log = logging.getLogger(__name__) - - -def setup(bot): - """Easter Egg Hunt Cog load.""" - - bot.add_cog(EggHunt()) - log.info("EggHunt cog loaded") diff --git a/bot/seasons/easter/egg_hunt/cog.py b/bot/seasons/easter/egg_hunt/cog.py deleted file mode 100644 index c9e2dc18..00000000 --- a/bot/seasons/easter/egg_hunt/cog.py +++ /dev/null @@ -1,638 +0,0 @@ -import asyncio -import contextlib -import logging -import random -import sqlite3 -from datetime import datetime, timezone -from pathlib import Path - -import discord -from discord.ext import commands - -from bot.constants import Channels, Client, Roles as MainRoles, bot -from bot.decorators import with_role -from .constants import Colours, EggHuntSettings, Emoji, Roles - -log = logging.getLogger(__name__) - -DB_PATH = Path("bot", "resources", "persist", "egg_hunt.sqlite") - -TEAM_MAP = { - Roles.white: Emoji.egg_white, - Roles.blurple: Emoji.egg_blurple, - Emoji.egg_white: Roles.white, - Emoji.egg_blurple: Roles.blurple -} - -GUILD = bot.get_guild(Client.guild) - -MUTED = GUILD.get_role(MainRoles.muted) - - -def get_team_role(user: discord.Member) -> discord.Role: - """Helper function to get the team role for a member.""" - - if Roles.white in user.roles: - return Roles.white - if Roles.blurple in user.roles: - return Roles.blurple - - -async def assign_team(user: discord.Member) -> discord.Member: - """Helper function to assign a new team role for a member.""" - - db = sqlite3.connect(DB_PATH) - c = db.cursor() - c.execute(f"SELECT team FROM user_scores WHERE user_id = {user.id}") - result = c.fetchone() - if not result: - c.execute( - "SELECT team, COUNT(*) AS count FROM user_scores " - "GROUP BY team ORDER BY count ASC LIMIT 1;" - ) - result = c.fetchone() - result = result[0] if result else "WHITE" - - if result[0] == "WHITE": - new_team = Roles.white - else: - new_team = Roles.blurple - - db.close() - - log.debug(f"Assigned role {new_team} to {user}.") - - await user.add_roles(new_team) - return GUILD.get_member(user.id) - - -class EggMessage: - """Handles a single egg reaction drop session.""" - - def __init__(self, message: discord.Message, egg: discord.Emoji): - self.message = message - self.egg = egg - self.first = None - self.users = set() - self.teams = {Roles.white: "WHITE", Roles.blurple: "BLURPLE"} - self.new_team_assignments = {} - self.timeout_task = None - - @staticmethod - def add_user_score_sql(user_id: int, team: str, score: int) -> str: - """Builds the SQL for adding a score to a user in the database.""" - - return ( - "INSERT INTO user_scores(user_id, team, score)" - f"VALUES({user_id}, '{team}', {score})" - f"ON CONFLICT (user_id) DO UPDATE SET score=score+{score}" - ) - - @staticmethod - def add_team_score_sql(team_name: str, score: int) -> str: - """Builds the SQL for adding a score to a team in the database.""" - - return f"UPDATE team_scores SET team_score=team_score+{score} WHERE team_id='{team_name}'" - - def finalise_score(self): - """Sums and actions scoring for this egg drop session.""" - - db = sqlite3.connect(DB_PATH) - c = db.cursor() - - team_scores = {"WHITE": 0, "BLURPLE": 0} - - first_team = get_team_role(self.first) - if not first_team: - log.debug("User without team role!") - db.close() - return - - score = 3 if first_team == TEAM_MAP[first_team] else 2 - - c.execute(self.add_user_score_sql(self.first.id, self.teams[first_team], score)) - team_scores[self.teams[first_team]] += score - - for user in self.users: - team = get_team_role(user) - if not team: - log.debug("User without team role!") - continue - - team_name = self.teams[team] - team_scores[team_name] += 1 - score = 2 if team == first_team else 1 - c.execute(self.add_user_score_sql(user.id, team_name, score)) - - for team_name, score in team_scores.items(): - if not score: - continue - c.execute(self.add_team_score_sql(team_name, score)) - - db.commit() - db.close() - - log.debug( - f"EggHunt session finalising: ID({self.message.id}) " - f"FIRST({self.first}) REST({self.users})." - ) - - async def start_timeout(self, seconds: int = 5): - """Begins a task that will sleep until the given seconds before finalizing the session.""" - - if self.timeout_task: - self.timeout_task.cancel() - self.timeout_task = None - - await asyncio.sleep(seconds) - - bot.remove_listener(self.collect_reacts, name="on_reaction_add") - - with contextlib.suppress(discord.Forbidden): - await self.message.clear_reactions() - - if self.first: - self.finalise_score() - - def is_valid_react(self, reaction: discord.Reaction, user: discord.Member) -> bool: - """Validates a reaction event was meant for this session.""" - - if user.bot: - return False - if reaction.message.id != self.message.id: - return False - if reaction.emoji != self.egg: - return False - - # ignore the pushished - if MUTED in user.roles: - return False - - return True - - async def collect_reacts(self, reaction: discord.Reaction, user: discord.Member): - """Handles emitted reaction_add events via listener.""" - - if not self.is_valid_react(reaction, user): - return - - team = get_team_role(user) - if not team: - log.debug(f"Assigning a team for {user}.") - user = await assign_team(user) - - if not self.first: - log.debug(f"{user} was first to react to egg on {self.message.id}.") - self.first = user - await self.start_timeout() - else: - if user != self.first: - self.users.add(user) - - async def start(self): - """Starts the egg drop session.""" - - log.debug(f"EggHunt session started for message {self.message.id}.") - bot.add_listener(self.collect_reacts, name="on_reaction_add") - with contextlib.suppress(discord.Forbidden): - await self.message.add_reaction(self.egg) - self.timeout_task = asyncio.create_task(self.start_timeout(300)) - while True: - if not self.timeout_task: - break - if not self.timeout_task.done(): - await self.timeout_task - else: - # make sure any exceptions raise if necessary - self.timeout_task.result() - break - - -class SuperEggMessage(EggMessage): - """Handles a super egg session.""" - - def __init__(self, message: discord.Message, egg: discord.Emoji, window: int): - super().__init__(message, egg) - self.window = window - - async def finalise_score(self): - """Sums and actions scoring for this super egg session.""" - try: - message = await self.message.channel.fetch_message(self.message.id) - except discord.NotFound: - return - - count = 0 - white = 0 - blurple = 0 - react_users = [] - for reaction in message.reactions: - if reaction.emoji == self.egg: - react_users = await reaction.users().flatten() - for user in react_users: - team = get_team_role(user) - if team == Roles.white: - white += 1 - elif team == Roles.blurple: - blurple += 1 - count = reaction.count - 1 - break - - score = 50 if self.egg == Emoji.egg_gold else 100 - if white == blurple: - log.debug("Tied SuperEgg Result.") - team = None - score /= 2 - elif white > blurple: - team = Roles.white - else: - team = Roles.blurple - - embed = self.message.embeds[0] - - db = sqlite3.connect(DB_PATH) - c = db.cursor() - - user_bonus = 5 if self.egg == Emoji.egg_gold else 10 - for user in react_users: - if user.bot: - continue - role = get_team_role(user) - if not role: - print("issue") - user_score = 1 if user != self.first else user_bonus - c.execute(self.add_user_score_sql(user.id, self.teams[role], user_score)) - - if not team: - embed.description = f"{embed.description}\n\nA Tie!\nBoth got {score} points!" - c.execute(self.add_team_score_sql(self.teams[Roles.white], score)) - c.execute(self.add_team_score_sql(self.teams[Roles.blurple], score)) - team_name = "TIE" - else: - team_name = self.teams[team] - embed.description = ( - f"{embed.description}\n\nTeam {team_name.capitalize()} won the points!" - ) - c.execute(self.add_team_score_sql(team_name, score)) - - c.execute( - "INSERT INTO super_eggs (message_id, egg_type, team, window) " - f"VALUES ({self.message.id}, '{self.egg.name}', '{team_name}', {self.window});" - ) - - log.debug("Committing Super Egg scores.") - db.commit() - db.close() - - embed.set_footer(text=f"Finished with {count} total reacts.") - with contextlib.suppress(discord.HTTPException): - await self.message.edit(embed=embed) - - async def start_timeout(self, seconds=None): - """Starts the super egg session.""" - - if not seconds: - return - count = 4 - for _ in range(count): - await asyncio.sleep(60) - embed = self.message.embeds[0] - embed.set_footer(text=f"Finishing in {count} minutes.") - try: - await self.message.edit(embed=embed) - except discord.HTTPException: - break - count -= 1 - bot.remove_listener(self.collect_reacts, name="on_reaction_add") - await self.finalise_score() - - -class EggHunt(commands.Cog): - """Easter Egg Hunt Event.""" - - def __init__(self): - self.event_channel = GUILD.get_channel(Channels.seasonalbot_chat) - self.super_egg_buffer = 60*60 - self.tables = { - "super_eggs": ( - "CREATE TABLE super_eggs (" - "message_id INTEGER NOT NULL " - " CONSTRAINT super_eggs_pk PRIMARY KEY, " - "egg_type TEXT NOT NULL, " - "team TEXT NOT NULL, " - "window INTEGER);" - ), - "team_scores": ( - "CREATE TABLE team_scores (" - "team_id TEXT, " - "team_score INTEGER DEFAULT 0);" - ), - "user_scores": ( - "CREATE TABLE user_scores(" - "user_id INTEGER NOT NULL " - " CONSTRAINT user_scores_pk PRIMARY KEY, " - "team TEXT NOT NULL, " - "score INTEGER DEFAULT 0 NOT NULL);" - ), - "react_logs": ( - "CREATE TABLE react_logs(" - "member_id INTEGER NOT NULL, " - "message_id INTEGER NOT NULL, " - "reaction_id TEXT NOT NULL, " - "react_timestamp REAL NOT NULL);" - ) - } - self.prepare_db() - self.task = asyncio.create_task(self.super_egg()) - self.task.add_done_callback(self.task_cleanup) - - def prepare_db(self): - """Ensures database tables all exist and if not, creates them.""" - - db = sqlite3.connect(DB_PATH) - c = db.cursor() - - exists_sql = "SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}';" - - missing_tables = [] - for table in self.tables: - c.execute(exists_sql.format(table_name=table)) - result = c.fetchone() - if not result: - missing_tables.append(table) - - for table in missing_tables: - log.info(f"Table {table} is missing, building new one.") - c.execute(self.tables[table]) - - db.commit() - db.close() - - def task_cleanup(self, task): - """Returns task result and restarts. Used as a done callback to show raised exceptions.""" - - task.result() - self.task = asyncio.create_task(self.super_egg()) - - @staticmethod - def current_timestamp() -> float: - """Returns a timestamp of the current UTC time.""" - - return datetime.utcnow().replace(tzinfo=timezone.utc).timestamp() - - async def super_egg(self): - """Manages the timing of super egg drops.""" - - while True: - now = int(self.current_timestamp()) - - if now > EggHuntSettings.end_time: - log.debug("Hunt ended. Ending task.") - break - - if now < EggHuntSettings.start_time: - remaining = EggHuntSettings.start_time - now - log.debug(f"Hunt not started yet. Sleeping for {remaining}.") - await asyncio.sleep(remaining) - - log.debug(f"Hunt started.") - - db = sqlite3.connect(DB_PATH) - c = db.cursor() - - current_window = None - next_window = None - windows = EggHuntSettings.windows.copy() - windows.insert(0, EggHuntSettings.start_time) - for i, window in enumerate(windows): - c.execute(f"SELECT COUNT(*) FROM super_eggs WHERE window={window}") - already_dropped = c.fetchone()[0] - - if already_dropped: - log.debug(f"Window {window} already dropped, checking next one.") - continue - - if now < window: - log.debug("Drop windows up to date, sleeping until next one.") - await asyncio.sleep(window-now) - now = int(self.current_timestamp()) - - current_window = window - next_window = windows[i+1] - break - - count = c.fetchone() - db.close() - - if not current_window: - log.debug("No drop windows left, ending task.") - break - - log.debug(f"Current Window: {current_window}. Next Window {next_window}") - - if not count: - if next_window < now: - log.debug("An Egg Drop Window was missed, dropping one now.") - next_drop = 0 - else: - next_drop = random.randrange(now, next_window) - - if next_drop: - log.debug(f"Sleeping until next super egg drop: {next_drop}.") - await asyncio.sleep(next_drop) - - if random.randrange(10) <= 2: - egg = Emoji.egg_diamond - egg_type = "Diamond" - score = "100" - colour = Colours.diamond - else: - egg = Emoji.egg_gold - egg_type = "Gold" - score = "50" - colour = Colours.gold - - embed = discord.Embed( - title=f"A {egg_type} Egg Has Appeared!", - description=f"**Worth {score} team points!**\n\n" - "The team with the most reactions after 5 minutes wins!", - colour=colour - ) - embed.set_thumbnail(url=egg.url) - embed.set_footer(text="Finishing in 5 minutes.") - msg = await self.event_channel.send(embed=embed) - await SuperEggMessage(msg, egg, current_window).start() - - log.debug("Sleeping until next window.") - next_loop = max(next_window - int(self.current_timestamp()), self.super_egg_buffer) - await asyncio.sleep(next_loop) - - @commands.Cog.listener() - async def on_raw_reaction_add(self, payload): - """Reaction event listener for reaction logging for later anti-cheat analysis.""" - - if payload.channel_id not in EggHuntSettings.allowed_channels: - return - - now = self.current_timestamp() - db = sqlite3.connect(DB_PATH) - c = db.cursor() - c.execute( - "INSERT INTO react_logs(member_id, message_id, reaction_id, react_timestamp) " - f"VALUES({payload.user_id}, {payload.message_id}, '{payload.emoji}', {now})" - ) - db.commit() - db.close() - - @commands.Cog.listener() - async def on_message(self, message): - """Message event listener for random egg drops.""" - - if self.current_timestamp() < EggHuntSettings.start_time: - return - - if message.channel.id not in EggHuntSettings.allowed_channels: - log.debug("Message not in Egg Hunt channel; ignored.") - return - - if message.author.bot: - return - - if random.randrange(100) <= 5: - await EggMessage(message, random.choice([Emoji.egg_white, Emoji.egg_blurple])).start() - - @commands.group(invoke_without_command=True) - async def hunt(self, ctx): - """ - For 48 hours, hunt down as many eggs randomly appearing as possible. - - Standard Eggs - -------------- - Egg React: +1pt - Team Bonus for Claimed Egg: +1pt - First React on Other Team Egg: +1pt - First React on Your Team Egg: +2pt - - If you get first react, you will claim that egg for your team, allowing - your team to get the Team Bonus point, but be quick, as the egg will - disappear after 5 seconds of the first react. - - Super Eggs - ----------- - Gold Egg: 50 team pts, 5pts to first react - Diamond Egg: 100 team pts, 10pts to first react - - Super Eggs only appear in #seasonalbot-chat so be sure to keep an eye - out. They stay around for 5 minutes and the team with the most reacts - wins the points. - """ - - await ctx.invoke(bot.get_command("help"), command="hunt") - - @hunt.command() - async def countdown(self, ctx): - """Show the time status of the Egg Hunt event.""" - - now = self.current_timestamp() - if now > EggHuntSettings.end_time: - return await ctx.send("The Hunt has ended.") - - difference = EggHuntSettings.start_time - now - if difference < 0: - difference = EggHuntSettings.end_time - now - msg = "The Egg Hunt will end in" - else: - msg = "The Egg Hunt will start in" - - hours, r = divmod(difference, 3600) - minutes, r = divmod(r, 60) - await ctx.send(f"{msg} {hours:.0f}hrs, {minutes:.0f}mins & {r:.0f}secs") - - @hunt.command() - async def leaderboard(self, ctx): - """Show the Egg Hunt Leaderboards.""" - - db = sqlite3.connect(DB_PATH) - c = db.cursor() - c.execute(f"SELECT *, RANK() OVER(ORDER BY score DESC) AS rank FROM user_scores LIMIT 10") - user_result = c.fetchall() - c.execute(f"SELECT * FROM team_scores ORDER BY team_score DESC") - team_result = c.fetchall() - db.close() - output = [] - if user_result: - # Get the alignment needed for the score - score_lengths = [] - for result in user_result: - length = len(str(result[2])) - score_lengths.append(length) - - score_length = max(score_lengths) - for user_id, team, score, rank in user_result: - user = GUILD.get_member(user_id) or user_id - team = team.capitalize() - score = f"{score}pts" - output.append(f"{rank:>2}. {score:>{score_length+3}} - {user} ({team})") - user_board = "\n".join(output) - else: - user_board = "No entries." - if team_result: - output = [] - for team, score in team_result: - output.append(f"{team:<7}: {score}") - team_board = "\n".join(output) - else: - team_board = "No entries." - embed = discord.Embed( - title="Egg Hunt Leaderboards", - description=f"**Team Scores**\n```\n{team_board}\n```\n" - f"**Top 10 Members**\n```\n{user_board}\n```" - ) - await ctx.send(embed=embed) - - @hunt.command() - async def rank(self, ctx, *, member: discord.Member = None): - """Get your ranking in the Egg Hunt Leaderboard.""" - - member = member or ctx.author - db = sqlite3.connect(DB_PATH) - c = db.cursor() - c.execute( - "SELECT rank FROM " - "(SELECT RANK() OVER(ORDER BY score DESC) AS rank, user_id FROM user_scores)" - f"WHERE user_id = {member.id};" - ) - result = c.fetchone() - db.close() - if not result: - embed = discord.Embed().set_author(name=f"Egg Hunt - No Ranking") - else: - embed = discord.Embed().set_author(name=f"Egg Hunt - Rank #{result[0]}") - await ctx.send(embed=embed) - - @with_role(MainRoles.admin) - @hunt.command() - async def clear_db(self, ctx): - """Resets the database to it's initial state.""" - - def check(msg): - if msg.author != ctx.author: - return False - if msg.channel != ctx.channel: - return False - return True - await ctx.send( - "WARNING: This will delete all current event data.\n" - "Please verify this action by replying with 'Yes, I want to delete all data.'" - ) - reply_msg = await bot.wait_for('message', check=check) - if reply_msg.content != "Yes, I want to delete all data.": - return await ctx.send("Reply did not match. Aborting database deletion.") - db = sqlite3.connect(DB_PATH) - c = db.cursor() - c.execute("DELETE FROM super_eggs;") - c.execute("DELETE FROM user_scores;") - c.execute("UPDATE team_scores SET team_score=0") - db.commit() - db.close() - await ctx.send("Database successfully cleared.") diff --git a/bot/seasons/easter/egg_hunt/constants.py b/bot/seasons/easter/egg_hunt/constants.py deleted file mode 100644 index c7d9818b..00000000 --- a/bot/seasons/easter/egg_hunt/constants.py +++ /dev/null @@ -1,39 +0,0 @@ -import os - -from discord import Colour - -from bot.constants import Channels, Client, bot - - -GUILD = bot.get_guild(Client.guild) - - -class EggHuntSettings: - start_time = int(os.environ["HUNT_START"]) - end_time = start_time + 172800 # 48 hrs later - windows = [int(w) for w in os.environ.get("HUNT_WINDOWS").split(',')] or [] - allowed_channels = [ - Channels.seasonalbot_chat, - Channels.off_topic_0, - Channels.off_topic_1, - Channels.off_topic_2, - ] - - -class Roles: - white = GUILD.get_role(569304397054607363) - blurple = GUILD.get_role(569304472820514816) - - -class Emoji: - egg_white = bot.get_emoji(569266762428841989) - egg_blurple = bot.get_emoji(569266666094067819) - egg_gold = bot.get_emoji(569266900106739712) - egg_diamond = bot.get_emoji(569266839738384384) - - -class Colours: - white = Colour(0xFFFFFF) - blurple = Colour(0x7289DA) - gold = Colour(0xE4E415) - diamond = Colour(0xECF5FF) diff --git a/bot/seasons/easter/egghead_quiz.py b/bot/seasons/easter/egghead_quiz.py index 8dd2c21d..bd179fe2 100644 --- a/bot/seasons/easter/egghead_quiz.py +++ b/bot/seasons/easter/egghead_quiz.py @@ -3,6 +3,7 @@ import logging import random from json import load from pathlib import Path +from typing import Union import discord from discord.ext import commands @@ -11,7 +12,7 @@ from bot.constants import Colours log = logging.getLogger(__name__) -with open(Path('bot', 'resources', 'easter', 'egghead_questions.json'), 'r', encoding="utf8") as f: +with open(Path("bot/resources/easter/egghead_questions.json"), "r", encoding="utf8") as f: EGGHEAD_QUESTIONS = load(f) @@ -30,18 +31,17 @@ TIMELIMIT = 30 class EggheadQuiz(commands.Cog): """This cog contains the command for the Easter quiz!""" - def __init__(self, bot): + def __init__(self, bot: commands.Bot) -> None: self.bot = bot self.quiz_messages = {} @commands.command(aliases=["eggheadquiz", "easterquiz"]) - async def eggquiz(self, ctx): + async def eggquiz(self, ctx: commands.Context) -> None: """ - Gives a random quiz question, waits 30 seconds and then outputs the answer + Gives a random quiz question, waits 30 seconds and then outputs the answer. Also informs of the percentages and votes of each option """ - random_question = random.choice(EGGHEAD_QUESTIONS) question, answers = random_question["question"], random_question["answers"] answers = [(EMOJIS[i], a) for i, a in enumerate(answers)] @@ -69,7 +69,7 @@ class EggheadQuiz(commands.Cog): total_no = sum([len(await r.users().flatten()) for r in msg.reactions]) - len(valid_emojis) # - bot's reactions if total_no == 0: - return await msg.delete() # to avoid ZeroDivisionError if nobody reacts + return await msg.delete() # To avoid ZeroDivisionError if nobody reacts results = ["**VOTES:**"] for emoji, _ in answers: @@ -96,14 +96,14 @@ class EggheadQuiz(commands.Cog): await ctx.send(content, embed=a_embed) @staticmethod - async def already_reacted(message, user): - """Returns whether a given user has reacted more than once to a given message""" + async def already_reacted(message: discord.Message, user: Union[discord.Member, discord.User]) -> bool: + """Returns whether a given user has reacted more than once to a given message.""" users = [u.id for reaction in [await r.users().flatten() for r in message.reactions] for u in reaction] return users.count(user.id) > 1 # Old reaction plus new reaction @commands.Cog.listener() - async def on_reaction_add(self, reaction, user): - """Listener to listen specifically for reactions of quiz messages""" + async def on_reaction_add(self, reaction: discord.Reaction, user: Union[discord.Member, discord.User]) -> None: + """Listener to listen specifically for reactions of quiz messages.""" if user.bot: return if reaction.message.id not in self.quiz_messages: @@ -114,8 +114,7 @@ class EggheadQuiz(commands.Cog): return await reaction.message.remove_reaction(reaction, user) -def setup(bot): - """Cog load.""" - +def setup(bot: commands.Bot) -> None: + """Egghead Quiz Cog load.""" bot.add_cog(EggheadQuiz(bot)) log.info("EggheadQuiz bot loaded") diff --git a/bot/seasons/easter/traditions.py b/bot/seasons/easter/traditions.py index 05cd79f3..9529823f 100644 --- a/bot/seasons/easter/traditions.py +++ b/bot/seasons/easter/traditions.py @@ -7,27 +7,25 @@ from discord.ext import commands log = logging.getLogger(__name__) -with open(Path('bot', 'resources', 'easter', 'traditions.json'), 'r', encoding="utf8") as f: +with open(Path("bot/resources/easter/traditions.json"), "r", encoding="utf8") as f: traditions = json.load(f) class Traditions(commands.Cog): """A cog which allows users to get a random easter tradition or custom from a random country.""" - def __init__(self, bot): + def __init__(self, bot: commands.Bot): self.bot = bot @commands.command(aliases=('eastercustoms',)) - async def easter_tradition(self, ctx): - """Responds with a random tradition or custom""" - + async def easter_tradition(self, ctx: commands.Context) -> None: + """Responds with a random tradition or custom.""" random_country = random.choice(list(traditions)) await ctx.send(f"{random_country}:\n{traditions[random_country]}") -def setup(bot): +def setup(bot: commands.Bot) -> None: """Traditions Cog load.""" - bot.add_cog(Traditions(bot)) log.info("Traditions cog loaded") diff --git a/bot/seasons/evergreen/8bitify.py b/bot/seasons/evergreen/8bitify.py new file mode 100644 index 00000000..60062fc1 --- /dev/null +++ b/bot/seasons/evergreen/8bitify.py @@ -0,0 +1,54 @@ +from io import BytesIO + +import discord +from PIL import Image +from discord.ext import commands + + +class EightBitify(commands.Cog): + """Make your avatar 8bit!""" + + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + + @staticmethod + def pixelate(image: Image) -> Image: + """Takes an image and pixelates it.""" + return image.resize((32, 32)).resize((1024, 1024)) + + @staticmethod + def quantize(image: Image) -> Image: + """Reduces colour palette to 256 colours.""" + return image.quantize(colors=32) + + @commands.command(name="8bitify") + async def eightbit_command(self, ctx: commands.Context) -> None: + """Pixelates your avatar and changes the palette to an 8bit one.""" + async with ctx.typing(): + image_bytes = await ctx.author.avatar_url.read() + avatar = Image.open(BytesIO(image_bytes)) + avatar = avatar.convert("RGBA").resize((1024, 1024)) + + eightbit = self.pixelate(avatar) + eightbit = self.quantize(eightbit) + + bufferedio = BytesIO() + eightbit.save(bufferedio, format="PNG") + bufferedio.seek(0) + + file = discord.File(bufferedio, filename="8bitavatar.png") + + embed = discord.Embed( + title="Your 8-bit avatar", + description='Here is your avatar. I think it looks all cool and "retro"' + ) + + embed.set_image(url="attachment://8bitavatar.png") + embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) + + await ctx.send(file=file, embed=embed) + + +def setup(bot: commands.Bot) -> None: + """Cog load.""" + bot.add_cog(EightBitify(bot)) diff --git a/bot/seasons/evergreen/__init__.py b/bot/seasons/evergreen/__init__.py index ac32c199..b95f3528 100644 --- a/bot/seasons/evergreen/__init__.py +++ b/bot/seasons/evergreen/__init__.py @@ -5,3 +5,9 @@ class Evergreen(SeasonBase): """Evergreen Seasonal event attributes.""" bot_icon = "/logos/logo_seasonal/evergreen/logo_evergreen.png" + icon = ( + "/logos/logo_animated/heartbeat/heartbeat.gif", + "/logos/logo_animated/spinner/spinner.gif", + "/logos/logo_animated/tongues/tongues.gif", + "/logos/logo_animated/winky/winky.gif", + ) diff --git a/bot/seasons/evergreen/error_handler.py b/bot/seasons/evergreen/error_handler.py index 26afe814..120462ee 100644 --- a/bot/seasons/evergreen/error_handler.py +++ b/bot/seasons/evergreen/error_handler.py @@ -1,21 +1,26 @@ import logging
import math
+import random
import sys
import traceback
+from discord import Colour, Embed, Message
from discord.ext import commands
+from bot.constants import NEGATIVE_REPLIES
+from bot.decorators import InChannelCheckFailure
+
log = logging.getLogger(__name__)
class CommandErrorHandler(commands.Cog):
"""A error handler for the PythonDiscord server."""
- def __init__(self, bot):
+ def __init__(self, bot: commands.Bot):
self.bot = bot
@staticmethod
- def revert_cooldown_counter(command, message):
+ def revert_cooldown_counter(command: commands.Command, message: Message) -> None:
"""Undoes the last cooldown counter for user-error cases."""
if command._buckets.valid:
bucket = command._buckets.get_bucket(message)
@@ -25,9 +30,8 @@ class CommandErrorHandler(commands.Cog): )
@commands.Cog.listener()
- async def on_command_error(self, ctx, error):
+ async def on_command_error(self, ctx: commands.Context, error: commands.CommandError) -> None:
"""Activates when a command opens an error."""
-
if hasattr(ctx.command, 'on_error'):
return logging.debug(
"A command error occured but the command had it's own error handler."
@@ -35,6 +39,16 @@ class CommandErrorHandler(commands.Cog): error = getattr(error, 'original', error)
+ if isinstance(error, InChannelCheckFailure):
+ logging.debug(
+ f"{ctx.author} the command '{ctx.command}', but they did not have "
+ f"permissions to run commands in the channel {ctx.channel}!"
+ )
+ embed = Embed(colour=Colour.red())
+ embed.title = random.choice(NEGATIVE_REPLIES)
+ embed.description = str(error)
+ return await ctx.send(embed=embed)
+
if isinstance(error, commands.CommandNotFound):
return logging.debug(
f"{ctx.author} called '{ctx.message.content}' but no command was found."
@@ -99,8 +113,7 @@ class CommandErrorHandler(commands.Cog): traceback.print_exception(type(error), error, error.__traceback__, file=sys.stderr)
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
"""Error handler Cog load."""
-
bot.add_cog(CommandErrorHandler(bot))
log.info("CommandErrorHandler cog loaded")
diff --git a/bot/seasons/evergreen/fun.py b/bot/seasons/evergreen/fun.py index 05cf504e..889ae079 100644 --- a/bot/seasons/evergreen/fun.py +++ b/bot/seasons/evergreen/fun.py @@ -1,23 +1,40 @@ +import functools import logging import random +from typing import Callable, Tuple, Union +from discord import Embed, Message from discord.ext import commands +from discord.ext.commands import Bot, Cog, Context, MessageConverter +from bot import utils from bot.constants import Emojis log = logging.getLogger(__name__) +UWU_WORDS = { + "fi": "fwi", + "l": "w", + "r": "w", + "some": "sum", + "th": "d", + "thing": "fing", + "tho": "fo", + "you're": "yuw'we", + "your": "yur", + "you": "yuw", +} -class Fun(commands.Cog): + +class Fun(Cog): """A collection of general commands for fun.""" - def __init__(self, bot): + def __init__(self, bot: Bot) -> None: self.bot = bot @commands.command() - async def roll(self, ctx, num_rolls: int = 1): + async def roll(self, ctx: Context, num_rolls: int = 1) -> None: """Outputs a number of random dice emotes (up to 6).""" - output = "" if num_rolls > 6: num_rolls = 6 @@ -28,9 +45,104 @@ class Fun(commands.Cog): output += getattr(Emojis, terning, '') await ctx.send(output) + @commands.command(name="uwu", aliases=("uwuwize", "uwuify",)) + async def uwu_command(self, ctx: Context, *, text: str) -> None: + """ + Converts a given `text` into it's uwu equivalent. -def setup(bot): - """Fun Cog load.""" + Also accepts a valid discord Message ID or link. + """ + conversion_func = functools.partial( + utils.replace_many, replacements=UWU_WORDS, ignore_case=True, match_case=True + ) + text, embed = await Fun._get_text_and_embed(ctx, text) + # Convert embed if it exists + if embed is not None: + embed = Fun._convert_embed(conversion_func, embed) + converted_text = conversion_func(text) + # Don't put >>> if only embed present + if converted_text: + converted_text = f">>> {converted_text.lstrip('> ')}" + await ctx.send(content=converted_text, embed=embed) + + @commands.command(name="randomcase", aliases=("rcase", "randomcaps", "rcaps",)) + async def randomcase_command(self, ctx: Context, *, text: str) -> None: + """ + Randomly converts the casing of a given `text`. + + Also accepts a valid discord Message ID or link. + """ + def conversion_func(text: str) -> str: + """Randomly converts the casing of a given string.""" + return "".join( + char.upper() if round(random.random()) else char.lower() for char in text + ) + text, embed = await Fun._get_text_and_embed(ctx, text) + # Convert embed if it exists + if embed is not None: + embed = Fun._convert_embed(conversion_func, embed) + converted_text = conversion_func(text) + # Don't put >>> if only embed present + if converted_text: + converted_text = f">>> {converted_text.lstrip('> ')}" + await ctx.send(content=converted_text, embed=embed) + + @staticmethod + async def _get_text_and_embed(ctx: Context, text: str) -> Tuple[str, Union[Embed, None]]: + """ + Attempts to extract the text and embed from a possible link to a discord Message. + + Returns a tuple of: + str: If `text` is a valid discord Message, the contents of the message, else `text`. + Union[Embed, None]: The embed if found in the valid Message, else None + """ + embed = None + message = await Fun._get_discord_message(ctx, text) + if isinstance(message, Message): + text = message.content + # Take first embed because we can't send multiple embeds + if message.embeds: + embed = message.embeds[0] + return (text, embed) + + @staticmethod + async def _get_discord_message(ctx: Context, text: str) -> Union[Message, str]: + """ + Attempts to convert a given `text` to a discord Message object and return it. + + Conversion will succeed if given a discord Message ID or link. + Returns `text` if the conversion fails. + """ + try: + text = await MessageConverter().convert(ctx, text) + except commands.BadArgument: + log.debug(f"Input '{text:.20}...' is not a valid Discord Message") + return text + @staticmethod + def _convert_embed(func: Callable[[str, ], str], embed: Embed) -> Embed: + """ + Converts the text in an embed using a given conversion function, then return the embed. + + Only modifies the following fields: title, description, footer, fields + """ + embed_dict = embed.to_dict() + + embed_dict["title"] = func(embed_dict.get("title", "")) + embed_dict["description"] = func(embed_dict.get("description", "")) + + if "footer" in embed_dict: + embed_dict["footer"]["text"] = func(embed_dict["footer"].get("text", "")) + + if "fields" in embed_dict: + for field in embed_dict["fields"]: + field["name"] = func(field.get("name", "")) + field["value"] = func(field.get("value", "")) + + return Embed.from_dict(embed_dict) + + +def setup(bot: commands.Bot) -> None: + """Fun Cog load.""" bot.add_cog(Fun(bot)) log.info("Fun cog loaded") diff --git a/bot/seasons/evergreen/issues.py b/bot/seasons/evergreen/issues.py index fb85323f..438ab475 100644 --- a/bot/seasons/evergreen/issues.py +++ b/bot/seasons/evergreen/issues.py @@ -3,41 +3,55 @@ import logging import discord from discord.ext import commands +from bot.constants import Colours +from bot.decorators import override_in_channel + log = logging.getLogger(__name__) class Issues(commands.Cog): - """Cog that allows users to retrieve issues from GitHub""" + """Cog that allows users to retrieve issues from GitHub.""" - def __init__(self, bot): + def __init__(self, bot: commands.Bot): self.bot = bot @commands.command(aliases=("issues",)) - async def issue(self, ctx, number: int, repository: str = "seasonalbot", user: str = "python-discord"): - """Command to retrieve issues from a GitHub repository""" + @override_in_channel() + async def issue( + self, ctx: commands.Context, number: int, repository: str = "seasonalbot", user: str = "python-discord" + ) -> None: + """Command to retrieve issues from a GitHub repository.""" + api_url = f"https://api.github.com/repos/{user}/{repository}/issues/{number}" + failed_status = { + 404: f"Issue #{number} doesn't exist in the repository {user}/{repository}.", + 403: f"Rate limit exceeded. Please wait a while before trying again!" + } + + async with self.bot.http_session.get(api_url) as r: + json_data = await r.json() + response_code = r.status - url = f"https://api.github.com/repos/{user}/{repository}/issues/{str(number)}" - status = {"404": f"Issue #{str(number)} doesn't exist in the repository {user}/{repository}.", - "403": f"Rate limit exceeded. Please wait a while before trying again!"} + if response_code in failed_status: + return await ctx.send(failed_status[response_code]) - async with self.bot.http_session.get(url) as r: - json_data = await r.json() + repo_url = f"https://github.com/{user}/{repository}" + issue_embed = discord.Embed(colour=Colours.bright_green) + issue_embed.add_field(name="Repository", value=f"[{user}/{repository}]({repo_url})", inline=False) + issue_embed.add_field(name="Issue Number", value=f"#{number}", inline=False) + issue_embed.add_field(name="Status", value=json_data["state"].title()) + issue_embed.add_field(name="Link", value=json_data["html_url"], inline=False) - if str(r.status) in status: - return await ctx.send(status.get(str(r.status))) + description = json_data["body"] + if len(description) > 1024: + placeholder = " [...]" + description = f"{description[:1024 - len(placeholder)]}{placeholder}" - valid = discord.Embed(colour=0x00ff37) - valid.add_field(name="Repository", value=f"{user}/{repository}", inline=False) - valid.add_field(name="Issue Number", value=f"#{str(number)}", inline=False) - valid.add_field(name="Status", value=json_data.get("state").title()) - valid.add_field(name="Link", value=url, inline=False) - if len(json_data.get("body")) < 1024: - valid.add_field(name="Description", value=json_data.get("body"), inline=False) - await ctx.send(embed=valid) + issue_embed.add_field(name="Description", value=description, inline=False) + await ctx.send(embed=issue_embed) -def setup(bot): - """Cog Retrieves Issues From Github""" +def setup(bot: commands.Bot) -> None: + """Github Issues Cog Load.""" bot.add_cog(Issues(bot)) log.info("Issues cog loaded") diff --git a/bot/seasons/evergreen/magic_8ball.py b/bot/seasons/evergreen/magic_8ball.py index 0b4eeb62..e47ef454 100644 --- a/bot/seasons/evergreen/magic_8ball.py +++ b/bot/seasons/evergreen/magic_8ball.py @@ -11,14 +11,14 @@ log = logging.getLogger(__name__) class Magic8ball(commands.Cog): """A Magic 8ball command to respond to a user's question.""" - def __init__(self, bot): + def __init__(self, bot: commands.Bot): self.bot = bot - with open(Path("bot", "resources", "evergreen", "magic8ball.json"), "r") as file: + with open(Path("bot/resources/evergreen/magic8ball.json"), "r") as file: self.answers = json.load(file) @commands.command(name="8ball") - async def output_answer(self, ctx, *, question): - """Return a magic 8 ball answer from answers list.""" + async def output_answer(self, ctx: commands.Context, *, question: str) -> None: + """Return a Magic 8ball answer from answers list.""" if len(question.split()) >= 3: answer = random.choice(self.answers) await ctx.send(answer) @@ -26,8 +26,7 @@ class Magic8ball(commands.Cog): await ctx.send("Usage: .8ball <question> (minimum length of 3 eg: `will I win?`)") -def setup(bot): - """Magic 8ball cog load.""" - +def setup(bot: commands.Bot) -> None: + """Magic 8ball Cog load.""" bot.add_cog(Magic8ball(bot)) log.info("Magic8ball cog loaded") diff --git a/bot/seasons/evergreen/minesweeper.py b/bot/seasons/evergreen/minesweeper.py new file mode 100644 index 00000000..b0ba8145 --- /dev/null +++ b/bot/seasons/evergreen/minesweeper.py @@ -0,0 +1,285 @@ +import logging +import typing +from dataclasses import dataclass +from random import randint, random + +import discord +from discord.ext import commands + +from bot.constants import Client + +MESSAGE_MAPPING = { + 0: ":stop_button:", + 1: ":one:", + 2: ":two:", + 3: ":three:", + 4: ":four:", + 5: ":five:", + 6: ":six:", + 7: ":seven:", + 8: ":eight:", + 9: ":nine:", + 10: ":keycap_ten:", + "bomb": ":bomb:", + "hidden": ":grey_question:", + "flag": ":flag_black:", + "x": ":x:" +} + +log = logging.getLogger(__name__) + + +class CoordinateConverter(commands.Converter): + """Converter for Coordinates.""" + + async def convert(self, ctx: commands.Context, coordinate: str) -> typing.Tuple[int, int]: + """Take in a coordinate string and turn it into an (x, y) tuple.""" + if not 2 <= len(coordinate) <= 3: + raise commands.BadArgument('Invalid co-ordinate provided') + + coordinate = coordinate.lower() + if coordinate[0].isalpha(): + digit = coordinate[1:] + letter = coordinate[0] + else: + digit = coordinate[:-1] + letter = coordinate[-1] + + if not digit.isdigit(): + raise commands.BadArgument + + x = ord(letter) - ord('a') + y = int(digit) - 1 + + if (not 0 <= x <= 9) or (not 0 <= y <= 9): + raise commands.BadArgument + return x, y + + +GameBoard = typing.List[typing.List[typing.Union[str, int]]] + + +@dataclass +class Game: + """The data for a game.""" + + board: GameBoard + revealed: GameBoard + dm_msg: discord.Message + chat_msg: discord.Message + activated_on_server: bool + + +GamesDict = typing.Dict[int, Game] + + +class Minesweeper(commands.Cog): + """Play a game of Minesweeper.""" + + def __init__(self, bot: commands.Bot) -> None: + self.games: GamesDict = {} # Store the currently running games + + @commands.group(name='minesweeper', aliases=('ms',), invoke_without_command=True) + async def minesweeper_group(self, ctx: commands.Context) -> None: + """Commands for Playing Minesweeper.""" + await ctx.send_help(ctx.command) + + @staticmethod + def get_neighbours(x: int, y: int) -> typing.Generator[typing.Tuple[int, int], None, None]: + """Get all the neighbouring x and y including it self.""" + for x_ in [x - 1, x, x + 1]: + for y_ in [y - 1, y, y + 1]: + if x_ != -1 and x_ != 10 and y_ != -1 and y_ != 10: + yield x_, y_ + + def generate_board(self, bomb_chance: float) -> GameBoard: + """Generate a 2d array for the board.""" + board: GameBoard = [ + [ + "bomb" if random() <= bomb_chance else "number" + for _ in range(10) + ] for _ in range(10) + ] + + # make sure there is always a free cell + board[randint(0, 9)][randint(0, 9)] = "number" + + for y, row in enumerate(board): + for x, cell in enumerate(row): + if cell == "number": + # calculate bombs near it + bombs = 0 + for x_, y_ in self.get_neighbours(x, y): + if board[y_][x_] == "bomb": + bombs += 1 + board[y][x] = bombs + return board + + @staticmethod + def format_for_discord(board: GameBoard) -> str: + """Format the board as a string for Discord.""" + discord_msg = ( + ":stop_button: :regional_indicator_a::regional_indicator_b::regional_indicator_c:" + ":regional_indicator_d::regional_indicator_e::regional_indicator_f::regional_indicator_g:" + ":regional_indicator_h::regional_indicator_i::regional_indicator_j:\n\n" + ) + rows = [] + for row_number, row in enumerate(board): + new_row = f"{MESSAGE_MAPPING[row_number + 1]} " + new_row += "".join(MESSAGE_MAPPING[cell] for cell in row) + rows.append(new_row) + + discord_msg += "\n".join(rows) + return discord_msg + + @minesweeper_group.command(name="start") + async def start_command(self, ctx: commands.Context, bomb_chance: float = .2) -> None: + """Start a game of Minesweeper.""" + if ctx.author.id in self.games: # Player is already playing + await ctx.send(f"{ctx.author.mention} you already have a game running!", delete_after=2) + await ctx.message.delete(delay=2) + return + + # Add game to list + board: GameBoard = self.generate_board(bomb_chance) + revealed_board: GameBoard = [["hidden"] * 10 for _ in range(10)] + + if ctx.guild: + await ctx.send(f"{ctx.author.mention} is playing Minesweeper") + chat_msg = await ctx.send(f"Here's there board!\n{self.format_for_discord(revealed_board)}") + else: + chat_msg = None + + await ctx.author.send( + f"Play by typing: `{Client.prefix}ms reveal xy [xy]` or `{Client.prefix}ms flag xy [xy]` \n" + f"Close the game with `{Client.prefix}ms end`\n" + ) + dm_msg = await ctx.author.send(f"Here's your board!\n{self.format_for_discord(revealed_board)}") + + self.games[ctx.author.id] = Game( + board=board, + revealed=revealed_board, + dm_msg=dm_msg, + chat_msg=chat_msg, + activated_on_server=ctx.guild is not None + ) + + async def update_boards(self, ctx: commands.Context) -> None: + """Update both playing boards.""" + game = self.games[ctx.author.id] + await game.dm_msg.delete() + game.dm_msg = await ctx.author.send(f"Here's your board!\n{self.format_for_discord(game.revealed)}") + if game.activated_on_server: + await game.chat_msg.edit(content=f"Here's there board!\n{self.format_for_discord(game.revealed)}") + + @commands.dm_only() + @minesweeper_group.command(name="flag") + async def flag_command(self, ctx: commands.Context, *coordinates: CoordinateConverter) -> None: + """Place multiple flags on the board.""" + board: GameBoard = self.games[ctx.author.id].revealed + for x, y in coordinates: + if board[y][x] == "hidden": + board[y][x] = "flag" + + await self.update_boards(ctx) + + @staticmethod + def reveal_bombs(revealed: GameBoard, board: GameBoard) -> None: + """Reveals all the bombs.""" + for y, row in enumerate(board): + for x, cell in enumerate(row): + if cell == "bomb": + revealed[y][x] = cell + + async def lost(self, ctx: commands.Context) -> None: + """The player lost the game.""" + game = self.games[ctx.author.id] + self.reveal_bombs(game.revealed, game.board) + await ctx.author.send(":fire: You lost! :fire:") + if game.activated_on_server: + await game.chat_msg.channel.send(f":fire: {ctx.author.mention} just lost Minesweeper! :fire:") + + async def won(self, ctx: commands.Context) -> None: + """The player won the game.""" + game = self.games[ctx.author.id] + await ctx.author.send(":tada: You won! :tada:") + if game.activated_on_server: + await game.chat_msg.channel.send(f":tada: {ctx.author.mention} just won Minesweeper! :tada:") + + def reveal_zeros(self, revealed: GameBoard, board: GameBoard, x: int, y: int) -> None: + """Recursively reveal adjacent cells when a 0 cell is encountered.""" + for x_, y_ in self.get_neighbours(x, y): + if revealed[y_][x_] != "hidden": + continue + revealed[y_][x_] = board[y_][x_] + if board[y_][x_] == 0: + self.reveal_zeros(revealed, board, x_, y_) + + async def check_if_won(self, ctx: commands.Context, revealed: GameBoard, board: GameBoard) -> bool: + """Checks if a player has won.""" + if any( + revealed[y][x] in ["hidden", "flag"] and board[y][x] != "bomb" + for x in range(10) + for y in range(10) + ): + return False + else: + await self.won(ctx) + return True + + async def reveal_one( + self, + ctx: commands.Context, + revealed: GameBoard, + board: GameBoard, + x: int, + y: int + ) -> bool: + """ + Reveal one square. + + return is True if the game ended, breaking the loop in `reveal_command` and deleting the game + """ + revealed[y][x] = board[y][x] + if board[y][x] == "bomb": + await self.lost(ctx) + revealed[y][x] = "x" # mark bomb that made you lose with a x + return True + elif board[y][x] == 0: + self.reveal_zeros(revealed, board, x, y) + return await self.check_if_won(ctx, revealed, board) + + @commands.dm_only() + @minesweeper_group.command(name="reveal") + async def reveal_command(self, ctx: commands.Context, *coordinates: CoordinateConverter) -> None: + """Reveal multiple cells.""" + game = self.games[ctx.author.id] + revealed: GameBoard = game.revealed + board: GameBoard = game.board + + for x, y in coordinates: + # reveal_one returns True if the revealed cell is a bomb or the player won, ending the game + if await self.reveal_one(ctx, revealed, board, x, y): + await self.update_boards(ctx) + del self.games[ctx.author.id] + break + else: + await self.update_boards(ctx) + + @minesweeper_group.command(name="end") + async def end_command(self, ctx: commands.Context) -> None: + """End your current game.""" + game = self.games[ctx.author.id] + game.revealed = game.board + await self.update_boards(ctx) + new_msg = f":no_entry: Game canceled :no_entry:\n{game.dm_msg.content}" + await game.dm_msg.edit(content=new_msg) + if game.activated_on_server: + await game.chat_msg.edit(content=new_msg) + del self.games[ctx.author.id] + + +def setup(bot: commands.Bot) -> None: + """Load the Minesweeper cog.""" + bot.add_cog(Minesweeper(bot)) + log.info("Minesweeper cog loaded") diff --git a/bot/seasons/evergreen/recommend_game.py b/bot/seasons/evergreen/recommend_game.py new file mode 100644 index 00000000..835a4e53 --- /dev/null +++ b/bot/seasons/evergreen/recommend_game.py @@ -0,0 +1,51 @@ +import json +import logging +from pathlib import Path +from random import shuffle + +import discord +from discord.ext import commands + +log = logging.getLogger(__name__) +game_recs = [] + +# Populate the list `game_recs` with resource files +for rec_path in Path("bot/resources/evergreen/game_recs").glob("*.json"): + with rec_path.open(encoding='utf-8') as file: + data = json.load(file) + game_recs.append(data) +shuffle(game_recs) + + +class RecommendGame(commands.Cog): + """Commands related to recommending games.""" + + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + self.index = 0 + + @commands.command(name="recommendgame", aliases=['gamerec']) + async def recommend_game(self, ctx: commands.Context) -> None: + """Sends an Embed of a random game recommendation.""" + if self.index >= len(game_recs): + self.index = 0 + shuffle(game_recs) + game = game_recs[self.index] + self.index += 1 + + author = self.bot.get_user(int(game['author'])) + + # Creating and formatting Embed + embed = discord.Embed(color=discord.Colour.blue()) + if author is not None: + embed.set_author(name=author.name, icon_url=author.avatar_url) + embed.set_image(url=game['image']) + embed.add_field(name='Recommendation: ' + game['title'] + '\n' + game['link'], value=game['description']) + + await ctx.send(embed=embed) + + +def setup(bot: commands.Bot) -> None: + """Loads the RecommendGame cog.""" + bot.add_cog(RecommendGame(bot)) + log.info("RecommendGame cog loaded") diff --git a/bot/seasons/evergreen/showprojects.py b/bot/seasons/evergreen/showprojects.py index d6223690..a943e548 100644 --- a/bot/seasons/evergreen/showprojects.py +++ b/bot/seasons/evergreen/showprojects.py @@ -1,5 +1,6 @@ import logging +from discord import Message from discord.ext import commands from bot.constants import Channels @@ -8,16 +9,15 @@ log = logging.getLogger(__name__) class ShowProjects(commands.Cog): - """Cog that reacts to posts in the #show-your-projects""" + """Cog that reacts to posts in the #show-your-projects.""" - def __init__(self, bot): + def __init__(self, bot: commands.Bot): self.bot = bot self.lastPoster = 0 # Given 0 as the default last poster ID as no user can actually have 0 assigned to them @commands.Cog.listener() - async def on_message(self, message): - """Adds reactions to posts in #show-your-projects""" - + async def on_message(self, message: Message) -> None: + """Adds reactions to posts in #show-your-projects.""" reactions = ["\U0001f44d", "\U00002764", "\U0001f440", "\U0001f389", "\U0001f680", "\U00002b50", "\U0001f6a9"] if (message.channel.id == Channels.show_your_projects and message.author.bot is False @@ -25,11 +25,10 @@ class ShowProjects(commands.Cog): for reaction in reactions: await message.add_reaction(reaction) - self.lastPoster = message.author.id - + self.lastPoster = message.author.id -def setup(bot): - """Show Projects Reaction Cog""" +def setup(bot: commands.Bot) -> None: + """Show Projects Reaction Cog.""" bot.add_cog(ShowProjects(bot)) log.info("ShowProjects cog loaded") diff --git a/bot/seasons/evergreen/snakes/__init__.py b/bot/seasons/evergreen/snakes/__init__.py index 5188200e..d7f9f20c 100644 --- a/bot/seasons/evergreen/snakes/__init__.py +++ b/bot/seasons/evergreen/snakes/__init__.py @@ -1,12 +1,13 @@ import logging +from discord.ext import commands + from bot.seasons.evergreen.snakes.snakes_cog import Snakes log = logging.getLogger(__name__) -def setup(bot): +def setup(bot: commands.Bot) -> None: """Snakes Cog load.""" - bot.add_cog(Snakes(bot)) log.info("Snakes cog loaded") diff --git a/bot/seasons/evergreen/snakes/converter.py b/bot/seasons/evergreen/snakes/converter.py index ec9c9870..57103b57 100644 --- a/bot/seasons/evergreen/snakes/converter.py +++ b/bot/seasons/evergreen/snakes/converter.py @@ -1,9 +1,10 @@ import json import logging import random +from typing import Iterable, List import discord -from discord.ext.commands import Converter +from discord.ext.commands import Context, Converter from fuzzywuzzy import fuzz from bot.seasons.evergreen.snakes.utils import SNAKE_RESOURCES @@ -18,16 +19,15 @@ class Snake(Converter): snakes = None special_cases = None - async def convert(self, ctx, name): + async def convert(self, ctx: Context, name: str) -> str: """Convert the input snake name to the closest matching Snake object.""" - await self.build_list() name = name.lower() if name == 'python': return 'Python (programming language)' - def get_potential(iterable, *, threshold=80): + def get_potential(iterable: Iterable, *, threshold: int = 80) -> List[str]: nonlocal name potential = [] @@ -59,9 +59,8 @@ class Snake(Converter): return names.get(name, name) @classmethod - async def build_list(cls): + async def build_list(cls) -> None: """Build list of snakes from the static snake resources.""" - # Get all the snakes if cls.snakes is None: with (SNAKE_RESOURCES / "snake_names.json").open() as snakefile: @@ -74,16 +73,13 @@ class Snake(Converter): cls.special_cases = {snake['name'].lower(): snake for snake in special_cases} @classmethod - async def random(cls): + async def random(cls) -> str: """ Get a random Snake from the loaded resources. This is stupid. We should find a way to somehow get the global session into a global context, so I can get it from here. - - :return: """ - await cls.build_list() names = [snake['scientific'] for snake in cls.snakes] return random.choice(names) diff --git a/bot/seasons/evergreen/snakes/snakes_cog.py b/bot/seasons/evergreen/snakes/snakes_cog.py index b5fb2881..1ed38f86 100644 --- a/bot/seasons/evergreen/snakes/snakes_cog.py +++ b/bot/seasons/evergreen/snakes/snakes_cog.py @@ -9,13 +9,13 @@ import textwrap import urllib from functools import partial from io import BytesIO -from typing import Any, Dict +from typing import Any, Dict, List import aiohttp import async_timeout from PIL import Image, ImageDraw, ImageFont from discord import Colour, Embed, File, Member, Message, Reaction -from discord.ext.commands import BadArgument, Bot, Cog, Context, bot_has_permissions, group +from discord.ext.commands import BadArgument, Bot, Cog, CommandError, Context, bot_has_permissions, group from bot.constants import ERROR_REPLIES, Tokens from bot.decorators import locked @@ -154,9 +154,8 @@ class Snakes(Cog): # region: Helper methods @staticmethod - def _beautiful_pastel(hue): + def _beautiful_pastel(hue: float) -> int: """Returns random bright pastels.""" - light = random.uniform(0.7, 0.85) saturation = 1 @@ -176,7 +175,6 @@ class Snakes(Cog): Written by juan and Someone during the first code jam. """ - snake = Image.open(buffer) # Get the size of the snake icon, configure the height of the image box (yes, it changes) @@ -252,9 +250,8 @@ class Snakes(Cog): return buffer @staticmethod - def _snakify(message): + def _snakify(message: str) -> str: """Sssnakifffiesss a sstring.""" - # Replace fricatives with exaggerated snake fricatives. simple_fricatives = [ "f", "s", "z", "h", @@ -275,9 +272,8 @@ class Snakes(Cog): return message - async def _fetch(self, session, url, params=None): + async def _fetch(self, session: aiohttp.ClientSession, url: str, params: dict = None) -> dict: """Asynchronous web request helper method.""" - if params is None: params = {} @@ -285,13 +281,12 @@ class Snakes(Cog): async with session.get(url, params=params) as response: return await response.json() - def _get_random_long_message(self, messages, retries=10): + def _get_random_long_message(self, messages: List[str], retries: int = 10) -> str: """ Fetch a message that's at least 3 words long, if possible to do so in retries attempts. Else, just return whatever the last message is. """ - long_message = random.choice(messages) if len(long_message.split()) < 3 and retries > 0: return self._get_random_long_message( @@ -308,11 +303,7 @@ class Snakes(Cog): Builds a dict that the .get() method can use. Created by Ava and eivl. - - :param name: The name of the snake to get information for - omit for a random snake - :return: A dict containing information on a snake """ - snake_info = {} async with aiohttp.ClientSession() as session: @@ -327,7 +318,7 @@ class Snakes(Cog): json = await self._fetch(session, URL, params=params) - # wikipedia does have a error page + # Wikipedia does have a error page try: pageid = json["query"]["search"][0]["pageid"] except KeyError: @@ -348,7 +339,7 @@ class Snakes(Cog): json = await self._fetch(session, URL, params=params) - # constructing dict - handle exceptions later + # Constructing dict - handle exceptions later try: snake_info["title"] = json["query"]["pages"][f"{pageid}"]["title"] snake_info["extract"] = json["query"]["pages"][f"{pageid}"]["extract"] @@ -380,7 +371,7 @@ class Snakes(Cog): ] for image in snake_info["images"]: - # images come in the format of `File:filename.extension` + # Images come in the format of `File:filename.extension` file, sep, filename = image["title"].partition(':') filename = filename.replace(" ", "%20") # Wikipedia returns good data! @@ -409,23 +400,13 @@ class Snakes(Cog): return snake_info async def _get_snake_name(self) -> Dict[str, str]: - """ - Gets a random snake name. - - :return: A random snake name, as a string. - """ + """Gets a random snake name.""" return random.choice(self.snake_names) - async def _validate_answer(self, ctx: Context, message: Message, answer: str, options: list): - """ - Validate the answer using a reaction event loop. - - :return: - """ - - def predicate(reaction, user): + async def _validate_answer(self, ctx: Context, message: Message, answer: str, options: list) -> None: + """Validate the answer using a reaction event loop.""" + def predicate(reaction: Reaction, user: Member) -> bool: """Test if the the answer is valid and can be evaluated.""" - return ( reaction.message.id == message.id # The reaction is attached to the question we asked. and user == ctx.author # It's the user who triggered the quiz. @@ -455,15 +436,14 @@ class Snakes(Cog): # region: Commands @group(name='snakes', aliases=('snake',), invoke_without_command=True) - async def snakes_group(self, ctx: Context): + async def snakes_group(self, ctx: Context) -> None: """Commands from our first code jam.""" - await ctx.send_help(ctx.command) @bot_has_permissions(manage_messages=True) @snakes_group.command(name='antidote') @locked() - async def antidote_command(self, ctx: Context): + async def antidote_command(self, ctx: Context) -> None: """ Antidote! Can you create the antivenom before the patient dies? @@ -478,10 +458,8 @@ class Snakes(Cog): This game was created by Lord Bisk and Runew0lf. """ - - def predicate(reaction_: Reaction, user_: Member): + def predicate(reaction_: Reaction, user_: Member) -> bool: """Make sure that this reaction is what we want to operate on.""" - return ( all(( # Reaction is on this message @@ -606,14 +584,13 @@ class Snakes(Cog): await board_id.clear_reactions() @snakes_group.command(name='draw') - async def draw_command(self, ctx: Context): + async def draw_command(self, ctx: Context) -> None: """ Draws a random snek using Perlin noise. Written by Momo and kel. Modified by juan and lemon. """ - with ctx.typing(): # Generate random snake attributes @@ -640,24 +617,19 @@ class Snakes(Cog): text_color=text_color, bg_color=bg_color ) - png_bytes = utils.frame_to_png_bytes(image_frame) - file = File(png_bytes, filename='snek.png') + png_bytesIO = utils.frame_to_png_bytes(image_frame) + file = File(png_bytesIO, filename='snek.png') await ctx.send(file=file) @snakes_group.command(name='get') @bot_has_permissions(manage_messages=True) @locked() - async def get_command(self, ctx: Context, *, name: Snake = None): + async def get_command(self, ctx: Context, *, name: Snake = None) -> None: """ Fetches information about a snake from Wikipedia. - :param ctx: Context object passed from discord.py - :param name: Optional, the name of the snake to get information - for - omit for a random snake - Created by Ava and eivl. """ - with ctx.typing(): if name is None: name = await Snake.random() @@ -700,14 +672,13 @@ class Snakes(Cog): @snakes_group.command(name='guess', aliases=('identify',)) @locked() - async def guess_command(self, ctx): + async def guess_command(self, ctx: Context) -> None: """ Snake identifying game. Made by Ava and eivl. Modified by lemon. """ - with ctx.typing(): image = None @@ -735,13 +706,12 @@ class Snakes(Cog): await self._validate_answer(ctx, guess, answer, options) @snakes_group.command(name='hatch') - async def hatch_command(self, ctx: Context): + async def hatch_command(self, ctx: Context) -> None: """ Hatches your personal snake. Written by Momo and kel. """ - # Pick a random snake to hatch. snake_name = random.choice(list(utils.snakes.keys())) snake_image = utils.snakes[snake_name] @@ -767,14 +737,13 @@ class Snakes(Cog): await ctx.channel.send(embed=my_snake_embed) @snakes_group.command(name='movie') - async def movie_command(self, ctx: Context): + async def movie_command(self, ctx: Context) -> None: """ Gets a random snake-related movie from OMDB. Written by Samuel. Modified by gdude. """ - url = "http://www.omdbapi.com/" page = random.randint(1, 27) @@ -838,14 +807,13 @@ class Snakes(Cog): @snakes_group.command(name='quiz') @locked() - async def quiz_command(self, ctx: Context): + async def quiz_command(self, ctx: Context) -> None: """ Asks a snake-related question in the chat and validates the user's guess. This was created by Mushy and Cardium, and modified by Urthas and lemon. """ - # Prepare a question. question = random.choice(self.snake_quizzes) answer = question["answerkey"] @@ -864,7 +832,7 @@ class Snakes(Cog): await self._validate_answer(ctx, quiz, answer, options) @snakes_group.command(name='name', aliases=('name_gen',)) - async def name_command(self, ctx: Context, *, name: str = None): + async def name_command(self, ctx: Context, *, name: str = None) -> None: """ Snakifies a username. @@ -886,7 +854,6 @@ class Snakes(Cog): This was written by Iceman, and modified for inclusion into the bot by lemon. """ - snake_name = await self._get_snake_name() snake_name = snake_name['name'] snake_prefix = "" @@ -937,15 +904,14 @@ class Snakes(Cog): @snakes_group.command(name='sal') @locked() - async def sal_command(self, ctx: Context): + async def sal_command(self, ctx: Context) -> None: """ Play a game of Snakes and Ladders. Written by Momo and kel. Modified by lemon. """ - - # check if there is already a game in this channel + # Check if there is already a game in this channel if ctx.channel in self.active_sal: await ctx.send(f"{ctx.author.mention} A game is already in progress in this channel.") return @@ -956,9 +922,8 @@ class Snakes(Cog): await game.open_game() @snakes_group.command(name='about') - async def about_command(self, ctx: Context): + async def about_command(self, ctx: Context) -> None: """Show an embed with information about the event, its participants, and its winners.""" - contributors = [ "<@!245270749919576066>", "<@!396290259907903491>", @@ -1000,13 +965,12 @@ class Snakes(Cog): await ctx.channel.send(embed=embed) @snakes_group.command(name='card') - async def card_command(self, ctx: Context, *, name: Snake = None): + async def card_command(self, ctx: Context, *, name: Snake = None) -> None: """ Create an interesting little card from a snake. Created by juan and Someone during the first code jam. """ - # Get the snake data we need if not name: name_obj = await self._get_snake_name() @@ -1039,14 +1003,13 @@ class Snakes(Cog): ) @snakes_group.command(name='fact') - async def fact_command(self, ctx: Context): + async def fact_command(self, ctx: Context) -> None: """ Gets a snake-related fact. Written by Andrew and Prithaj. Modified by lemon. """ - question = random.choice(self.snake_facts)["fact"] embed = Embed( title="Snake fact", @@ -1056,19 +1019,16 @@ class Snakes(Cog): await ctx.channel.send(embed=embed) @snakes_group.command(name='snakify') - async def snakify_command(self, ctx: Context, *, message: str = None): + async def snakify_command(self, ctx: Context, *, message: str = None) -> None: """ How would I talk if I were a snake? - :param ctx: context - :param message: If this is passed, it will snakify the message. - If not, it will snakify a random message from - the users history. + If `message` is passed, the bot will snakify the message. + Otherwise, a random message from the user's history is snakified. Written by Momo and kel. Modified by lemon. """ - with ctx.typing(): embed = Embed() user = ctx.message.author @@ -1100,16 +1060,14 @@ class Snakes(Cog): await ctx.channel.send(embed=embed) @snakes_group.command(name='video', aliases=('get_video',)) - async def video_command(self, ctx: Context, *, search: str = None): + async def video_command(self, ctx: Context, *, search: str = None) -> None: """ Gets a YouTube video about snakes. - :param ctx: Context object passed from discord.py - :param search: Optional, a name of a snake. Used to search for videos with that name + If `search` is given, a snake with that name will be searched on Youtube. Written by Andrew and Prithaj. """ - # Are we searching for anything specific? if search: query = search + ' snake' @@ -1142,14 +1100,13 @@ class Snakes(Cog): log.warning(f"YouTube API error. Full response looks like {response}") @snakes_group.command(name='zen') - async def zen_command(self, ctx: Context): + async def zen_command(self, ctx: Context) -> None: """ Gets a random quote from the Zen of Python, except as if spoken by a snake. Written by Prithaj and Andrew. Modified by lemon. """ - embed = Embed( title="Zzzen of Pythhon", color=SNAKE_COLOR @@ -1170,9 +1127,8 @@ class Snakes(Cog): @get_command.error @card_command.error @video_command.error - async def command_error(self, ctx, error): + async def command_error(self, ctx: Context, error: CommandError) -> None: """Local error handler for the Snake Cog.""" - embed = Embed() embed.colour = Colour.red() diff --git a/bot/seasons/evergreen/snakes/utils.py b/bot/seasons/evergreen/snakes/utils.py index a7cb70a7..7d6caf04 100644 --- a/bot/seasons/evergreen/snakes/utils.py +++ b/bot/seasons/evergreen/snakes/utils.py @@ -11,9 +11,11 @@ from typing import List, Tuple from PIL import Image from PIL.ImageDraw import ImageDraw from discord import File, Member, Reaction -from discord.ext.commands import Context +from discord.ext.commands import Cog, Context -SNAKE_RESOURCES = Path('bot', 'resources', 'snakes').absolute() +from bot.constants import Roles + +SNAKE_RESOURCES = Path("bot/resources/snakes").absolute() h1 = r'''``` ---- @@ -112,20 +114,17 @@ ANGLE_RANGE = math.pi * 2 def get_resource(file: str) -> List[dict]: """Load Snake resources JSON.""" - with (SNAKE_RESOURCES / f"{file}.json").open(encoding="utf-8") as snakefile: return json.load(snakefile) -def smoothstep(t): +def smoothstep(t: float) -> float: """Smooth curve with a zero derivative at 0 and 1, making it useful for interpolating.""" - return t * t * (3. - 2. * t) -def lerp(t, a, b): +def lerp(t: float, a: float, b: float) -> float: """Linear interpolation between a and b, given a fraction t.""" - return a + t * (b - a) @@ -141,7 +140,7 @@ class PerlinNoiseFactory(object): Licensed under ISC """ - def __init__(self, dimension, octaves=1, tile=(), unbias=False): + def __init__(self, dimension: int, octaves: int = 1, tile: Tuple[int] = (), unbias: bool = False): """ Create a new Perlin noise factory in the given number of dimensions. @@ -155,10 +154,9 @@ class PerlinNoiseFactory(object): This will produce noise that tiles every 3 units vertically, but never tiles horizontally. - If ``unbias`` is true, the smoothstep function will be applied to the output before returning + If ``unbias`` is True, the smoothstep function will be applied to the output before returning it, to counteract some of Perlin noise's significant bias towards the center of its output range. """ - self.dimension = dimension self.octaves = octaves self.tile = tile + (0,) * dimension @@ -170,13 +168,12 @@ class PerlinNoiseFactory(object): self.gradient = {} - def _generate_gradient(self): + def _generate_gradient(self) -> Tuple[float, ...]: """ Generate a random unit vector at each grid point. This is the "gradient" vector, in that the grid tile slopes towards it """ - # 1 dimension is special, since the only unit vector is trivial; # instead, use a slope between -1 and 1 if self.dimension == 1: @@ -191,9 +188,8 @@ class PerlinNoiseFactory(object): scale = sum(n * n for n in random_point) ** -0.5 return tuple(coord * scale for coord in random_point) - def get_plain_noise(self, *point): + def get_plain_noise(self, *point) -> float: """Get plain noise for a single point, without taking into account either octaves or tiling.""" - if len(point) != self.dimension: raise ValueError("Expected {0} values, got {1}".format( self.dimension, len(point))) @@ -240,13 +236,12 @@ class PerlinNoiseFactory(object): return dots[0] * self.scale_factor - def __call__(self, *point): + def __call__(self, *point) -> float: """ Get the value of this Perlin noise function at the given point. The number of values given should match the number of dimensions. """ - ret = 0 for o in range(self.octaves): o2 = 1 << o @@ -292,22 +287,9 @@ def create_snek_frame( """ Creates a single random snek frame using Perlin noise. - :param perlin_factory: the perlin noise factory used. Required. - :param perlin_lookup_vertical_shift: the Perlin noise shift in the Y-dimension for this frame - :param image_dimensions: the size of the output image. - :param image_margins: the margins to respect inside of the image. - :param snake_length: the length of the snake, in segments. - :param snake_color: the color of the snake. - :param bg_color: the background color. - :param segment_length_range: the range of the segment length. Values will be generated inside - this range, including the bounds. - :param snake_width: the width of the snek, in pixels. - :param text: the text to display with the snek. Set to None for no text. - :param text_position: the position of the text. - :param text_color: the color of the text. - :return: a PIL image, representing a single frame. + `perlin_lookup_vertical_shift` represents the Perlin noise shift in the Y-dimension for this frame. + If `text` is given, display the given text with the snek. """ - start_x = random.randint(image_margins[X], image_dimensions[X] - image_margins[X]) start_y = random.randint(image_margins[Y], image_dimensions[Y] - image_margins[Y]) points = [(start_x, start_y)] @@ -360,12 +342,12 @@ def create_snek_frame( return image -def frame_to_png_bytes(image: Image): +def frame_to_png_bytes(image: Image) -> io.BytesIO: """Convert image to byte stream.""" - stream = io.BytesIO() image.save(stream, format='PNG') - return stream.getvalue() + stream.seek(0) + return stream log = logging.getLogger(__name__) @@ -387,7 +369,7 @@ GAME_SCREEN_EMOJI = [ class SnakeAndLaddersGame: """Snakes and Ladders game Cog.""" - def __init__(self, snakes, context: Context): + def __init__(self, snakes: Cog, context: Context): self.snakes = snakes self.ctx = context self.channel = self.ctx.channel @@ -402,17 +384,14 @@ class SnakeAndLaddersGame: self.positions = None self.rolls = [] - async def open_game(self): + async def open_game(self) -> None: """ Create a new Snakes and Ladders game. - Listen for reactions until players have joined, - and the game has been started. + Listen for reactions until players have joined, and the game has been started. """ - - def startup_event_check(reaction_: Reaction, user_: Member): + def startup_event_check(reaction_: Reaction, user_: Member) -> bool: """Make sure that this reaction is what we want to operate on.""" - return ( all(( reaction_.message.id == startup.id, # Reaction is on startup message @@ -434,7 +413,6 @@ class SnakeAndLaddersGame: "**Snakes and Ladders**: A new game is about to start!", file=File( str(SNAKE_RESOURCES / "snakes_and_ladders" / "banner.jpg"), - # os.path.join("bot", "resources", "snakes", "snakes_and_ladders", "banner.jpg"), filename='Snakes and Ladders.jpg' ) ) @@ -457,8 +435,9 @@ class SnakeAndLaddersGame: if reaction.emoji == JOIN_EMOJI: await self.player_join(user) elif reaction.emoji == CANCEL_EMOJI: - if self.ctx.author == user: - await self.cancel_game(user) + if user == self.author or (self._is_moderator(user) and user not in self.players): + # Allow game author or non-playing moderation staff to cancel a waiting game + await self.cancel_game() return else: await self.player_leave(user) @@ -473,10 +452,11 @@ class SnakeAndLaddersGame: except asyncio.TimeoutError: log.debug("Snakes and Ladders timed out waiting for a reaction") - self.cancel_game(self.author) + await self.cancel_game() return # We're done, no reactions for the last 5 minutes - async def _add_player(self, user: Member): + async def _add_player(self, user: Member) -> None: + """Add player to game.""" self.players.append(user) self.player_tiles[user.id] = 1 @@ -484,14 +464,13 @@ class SnakeAndLaddersGame: im = Image.open(io.BytesIO(avatar_bytes)).resize((BOARD_PLAYER_SIZE, BOARD_PLAYER_SIZE)) self.avatar_images[user.id] = im - async def player_join(self, user: Member): + async def player_join(self, user: Member) -> None: """ Handle players joining the game. Prevent player joining if they have already joined, if the game is full, or if the game is in a waiting state. """ - for p in self.players: if user == p: await self.channel.send(user.mention + " You are already in the game.", delete_after=10) @@ -511,21 +490,16 @@ class SnakeAndLaddersGame: delete_after=10 ) - async def player_leave(self, user: Member): + async def player_leave(self, user: Member) -> bool: """ Handle players leaving the game. - Leaving is prevented if the user initiated the game or if they weren't part of it in the - first place. - """ + Leaving is prevented if the user wasn't part of the game. - if user == self.author: - await self.channel.send( - user.mention + " You are the author, and cannot leave the game. Execute " - "`sal cancel` to cancel the game.", - delete_after=10 - ) - return + If the number of players reaches 0, the game is terminated. In this case, a sentinel boolean + is returned True to prevent a game from continuing after it's destroyed. + """ + is_surrendered = False # Sentinel value to assist with stopping a surrendered game for p in self.players: if user == p: self.players.remove(p) @@ -536,52 +510,44 @@ class SnakeAndLaddersGame: delete_after=10 ) - if self.state != 'waiting' and len(self.players) == 1: + if self.state != 'waiting' and len(self.players) == 0: await self.channel.send("**Snakes and Ladders**: The game has been surrendered!") + is_surrendered = True self._destruct() - return - await self.channel.send(user.mention + " You are not in the match.", delete_after=10) - async def cancel_game(self, user: Member): - """Allow the game author to cancel the running game.""" + return is_surrendered + else: + await self.channel.send(user.mention + " You are not in the match.", delete_after=10) + return is_surrendered - if not user == self.author: - await self.channel.send(user.mention + " Only the author of the game can cancel it.", delete_after=10) - return + async def cancel_game(self) -> None: + """Cancel the running game.""" await self.channel.send("**Snakes and Ladders**: Game has been canceled.") self._destruct() - async def start_game(self, user: Member): + async def start_game(self, user: Member) -> None: """ Allow the game author to begin the game. - The game cannot be started if there aren't enough players joined or if the game is in a - waiting state. + The game cannot be started if the game is in a waiting state. """ - if not user == self.author: await self.channel.send(user.mention + " Only the author of the game can start it.", delete_after=10) return - if len(self.players) < 1: - await self.channel.send( - user.mention + " A minimum of 2 players is required to start the game.", - delete_after=10 - ) - return + if not self.state == 'waiting': await self.channel.send(user.mention + " The game cannot be started at this time.", delete_after=10) return + self.state = 'starting' player_list = ', '.join(user.mention for user in self.players) await self.channel.send("**Snakes and Ladders**: The game is starting!\nPlayers: " + player_list) await self.start_round() - async def start_round(self): + async def start_round(self) -> None: """Begin the round.""" - - def game_event_check(reaction_: Reaction, user_: Member): + def game_event_check(reaction_: Reaction, user_: Member) -> bool: """Make sure that this reaction is what we want to operate on.""" - return ( all(( reaction_.message.id == self.positions.id, # Reaction is on positions message @@ -593,8 +559,6 @@ class SnakeAndLaddersGame: self.state = 'roll' for user in self.players: self.round_has_rolled[user.id] = False - # board_img = Image.open(os.path.join( - # "bot", "resources", "snakes", "snakes_and_ladders", "board.jpg")) board_img = Image.open(str(SNAKE_RESOURCES / "snakes_and_ladders" / "board.jpg")) player_row_size = math.ceil(MAX_PLAYERS / 2) @@ -609,9 +573,8 @@ class SnakeAndLaddersGame: y_offset -= BOARD_PLAYER_SIZE * math.floor(i / player_row_size) board_img.paste(self.avatar_images[player.id], box=(x_offset, y_offset)) - stream = io.BytesIO() - board_img.save(stream, format='JPEG') - board_file = File(stream.getvalue(), filename='Board.jpg') + + board_file = File(frame_to_png_bytes(board_img), filename='Board.jpg') player_list = '\n'.join((user.mention + ": Tile " + str(self.player_tiles[user.id])) for user in self.players) # Store and send new messages @@ -641,6 +604,7 @@ class SnakeAndLaddersGame: for emoji in GAME_SCREEN_EMOJI: await self.positions.add_reaction(emoji) + is_surrendered = False while True: try: reaction, user = await self.ctx.bot.wait_for( @@ -652,11 +616,12 @@ class SnakeAndLaddersGame: if reaction.emoji == ROLL_EMOJI: await self.player_roll(user) elif reaction.emoji == CANCEL_EMOJI: - if self.ctx.author == user: - await self.cancel_game(user) + if self._is_moderator(user) and user not in self.players: + # Only allow non-playing moderation staff to cancel a running game + await self.cancel_game() return else: - await self.player_leave(user) + is_surrendered = await self.player_leave(user) await self.positions.remove_reaction(reaction.emoji, user) @@ -665,15 +630,17 @@ class SnakeAndLaddersGame: except asyncio.TimeoutError: log.debug("Snakes and Ladders timed out waiting for a reaction") - await self.cancel_game(self.author) + await self.cancel_game() return # We're done, no reactions for the last 5 minutes # Round completed - await self._complete_round() + # Check to see if the game was surrendered before completing the round, without this + # sentinel, the game object would be deleted but the next round still posted into purgatory + if not is_surrendered: + await self._complete_round() - async def player_roll(self, user: Member): + async def player_roll(self, user: Member) -> None: """Handle the player's roll.""" - if user.id not in self.player_tiles: await self.channel.send(user.mention + " You are not in the match.", delete_after=10) return @@ -704,7 +671,8 @@ class SnakeAndLaddersGame: self.player_tiles[user.id] = min(100, next_tile) self.round_has_rolled[user.id] = True - async def _complete_round(self): + async def _complete_round(self) -> None: + """At the conclusion of a round check to see if there's been a winner.""" self.state = 'post_round' # check for winner @@ -719,22 +687,30 @@ class SnakeAndLaddersGame: self._destruct() def _check_winner(self) -> Member: + """Return a winning member if we're in the post-round state and there's a winner.""" if self.state != 'post_round': return None return next((player for player in self.players if self.player_tiles[player.id] == 100), None) - def _check_all_rolled(self): + def _check_all_rolled(self) -> bool: + """Check if all members have made their roll.""" return all(rolled for rolled in self.round_has_rolled.values()) - def _destruct(self): + def _destruct(self) -> None: + """Clean up the finished game object.""" del self.snakes.active_sal[self.channel] - def _board_coordinate_from_index(self, index: int): - # converts the tile number to the x/y coordinates for graphical purposes + def _board_coordinate_from_index(self, index: int) -> Tuple[int, int]: + """Convert the tile number to the x/y coordinates for graphical purposes.""" y_level = 9 - math.floor((index - 1) / 10) is_reversed = math.floor((index - 1) / 10) % 2 != 0 x_level = (index - 1) % 10 if is_reversed: x_level = 9 - x_level return x_level, y_level + + @staticmethod + def _is_moderator(user: Member) -> bool: + """Return True if the user is a Moderator.""" + return any(Roles.moderator == role.id for role in user.roles) diff --git a/bot/seasons/evergreen/speedrun.py b/bot/seasons/evergreen/speedrun.py new file mode 100644 index 00000000..76c5e8d3 --- /dev/null +++ b/bot/seasons/evergreen/speedrun.py @@ -0,0 +1,28 @@ +import json +import logging +from pathlib import Path +from random import choice + +from discord.ext import commands + +log = logging.getLogger(__name__) +with Path('bot/resources/evergreen/speedrun_links.json').open(encoding="utf-8") as file: + LINKS = json.load(file) + + +class Speedrun(commands.Cog): + """Commands about the video game speedrunning community.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @commands.command(name="speedrun") + async def get_speedrun(self, ctx: commands.Context) -> None: + """Sends a link to a video of a random speedrun.""" + await ctx.send(choice(LINKS)) + + +def setup(bot: commands.Bot) -> None: + """Load the Speedrun cog.""" + bot.add_cog(Speedrun(bot)) + log.info("Speedrun cog loaded") diff --git a/bot/seasons/evergreen/uptime.py b/bot/seasons/evergreen/uptime.py index 32c2b59d..6f24f545 100644 --- a/bot/seasons/evergreen/uptime.py +++ b/bot/seasons/evergreen/uptime.py @@ -12,13 +12,12 @@ log = logging.getLogger(__name__) class Uptime(commands.Cog): """A cog for posting the bot's uptime.""" - def __init__(self, bot): + def __init__(self, bot: commands.Bot): self.bot = bot @commands.command(name="uptime") - async def uptime(self, ctx): + async def uptime(self, ctx: commands.Context) -> None: """Responds with the uptime of the bot.""" - difference = relativedelta(start_time - arrow.utcnow()) uptime_string = start_time.shift( seconds=-difference.seconds, @@ -29,8 +28,7 @@ class Uptime(commands.Cog): await ctx.send(f"I started up {uptime_string}.") -def setup(bot): +def setup(bot: commands.Bot) -> None: """Uptime Cog load.""" - bot.add_cog(Uptime(bot)) log.info("Uptime cog loaded") diff --git a/bot/seasons/halloween/8ball.py b/bot/seasons/halloween/8ball.py index af037e53..2e1c2804 100644 --- a/bot/seasons/halloween/8ball.py +++ b/bot/seasons/halloween/8ball.py @@ -8,18 +8,18 @@ from discord.ext import commands log = logging.getLogger(__name__) -with open(Path('bot', 'resources', 'halloween', 'responses.json'), 'r', encoding="utf8") as f: +with open(Path("bot/resources/halloween/responses.json"), "r", encoding="utf8") as f: responses = json.load(f) class SpookyEightBall(commands.Cog): """Spooky Eightball answers.""" - def __init__(self, bot): + def __init__(self, bot: commands.Bot): self.bot = bot @commands.command(aliases=('spooky8ball',)) - async def spookyeightball(self, ctx, *, question: str): + async def spookyeightball(self, ctx: commands.Context, *, question: str) -> None: """Responds with a random response to a question.""" choice = random.choice(responses['responses']) msg = await ctx.send(choice[0]) @@ -28,8 +28,7 @@ class SpookyEightBall(commands.Cog): await msg.edit(content=f"{choice[0]} \n{choice[1]}") -def setup(bot): +def setup(bot: commands.Bot) -> None: """Spooky Eight Ball Cog Load.""" - bot.add_cog(SpookyEightBall(bot)) log.info("SpookyEightBall cog loaded") diff --git a/bot/seasons/halloween/__init__.py b/bot/seasons/halloween/__init__.py index 74c962ed..c81879d7 100644 --- a/bot/seasons/halloween/__init__.py +++ b/bot/seasons/halloween/__init__.py @@ -3,14 +3,22 @@ from bot.seasons import SeasonBase class Halloween(SeasonBase): - """Halloween Seasonal event attributes.""" + """ + 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 = "Spookybot" + bot_name = "NeonBot" greeting = "Happy Halloween!" start_date = "01/10" - end_date = "31/10" + end_date = "01/11" - colour = Colours.orange - icon = "/logos/logo_seasonal/halloween/spooky.png" + 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 f8ab4c60..64da7ced 100644 --- a/bot/seasons/halloween/candy_collection.py +++ b/bot/seasons/halloween/candy_collection.py @@ -3,6 +3,7 @@ import json import logging import os import random +from typing import List, Union import discord from discord.ext import commands @@ -23,7 +24,7 @@ ADD_SKULL_EXISTING_REACTION_CHANCE = 20 # 5% class CandyCollection(commands.Cog): """Candy collection game Cog.""" - def __init__(self, bot): + def __init__(self, bot: commands.Bot): self.bot = bot with open(json_location) as candy: self.candy_json = json.load(candy) @@ -34,9 +35,8 @@ class CandyCollection(commands.Cog): self.get_candyinfo[userid] = userinfo @commands.Cog.listener() - async def on_message(self, message): + 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 if message.author.bot: return @@ -56,9 +56,8 @@ class CandyCollection(commands.Cog): return await message.add_reaction('\N{CANDY}') @commands.Cog.listener() - async def on_reaction_add(self, reaction, user): + 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 # check to ensure the reactor is human if user.bot: @@ -103,14 +102,13 @@ class CandyCollection(commands.Cog): self.candy_json['records'].append(d) await self.remove_reactions(reaction) - async def reacted_msg_chance(self, message): + async def reacted_msg_chance(self, message: discord.Message) -> None: """ Randomly add a skull or candy reaction to a message if there is a reaction there already. This event has a higher probability of occurring than a reaction add to a message without an existing reaction. """ - if random.randint(1, ADD_SKULL_EXISTING_REACTION_CHANCE) == 1: d = {"reaction": '\N{SKULL}', "msg_id": message.id, "won": False} self.msg_reacted.append(d) @@ -121,27 +119,26 @@ class CandyCollection(commands.Cog): self.msg_reacted.append(d) return await message.add_reaction('\N{CANDY}') - async def ten_recent_msg(self): + async def ten_recent_msg(self) -> List[int]: """Get the last 10 messages sent in the channel.""" - ten_recent = [] - recent_msg = max(message.id for message - in self.bot._connection._messages - if message.channel.id == Channels.seasonalbot_chat) + recent_msg_id = max( + message.id for message in self.bot._connection._messages + if message.channel.id == Channels.seasonalbot_chat + ) channel = await self.hacktober_channel() - ten_recent.append(recent_msg.id) + ten_recent.append(recent_msg_id) for i in range(9): - o = discord.Object(id=recent_msg.id + i) + o = discord.Object(id=recent_msg_id + i) msg = await next(channel.history(limit=1, before=o)) ten_recent.append(msg.id) return ten_recent - async def get_message(self, msg_id): + async def get_message(self, msg_id: int) -> Union[discord.Message, None]: """Get the message from its ID.""" - try: o = discord.Object(id=msg_id + 1) # Use history rather than get_message due to @@ -156,14 +153,12 @@ class CandyCollection(commands.Cog): except Exception: return None - async def hacktober_channel(self): + async def hacktober_channel(self) -> discord.TextChannel: """Get #hacktoberbot channel from its ID.""" - return self.bot.get_channel(id=Channels.seasonalbot_chat) - async def remove_reactions(self, reaction): + async def remove_reactions(self, reaction: discord.Reaction) -> None: """Remove all candy/skull reactions.""" - try: async for user in reaction.users(): await reaction.message.remove_reaction(reaction.emoji, user) @@ -171,25 +166,22 @@ class CandyCollection(commands.Cog): except discord.HTTPException: pass - async def send_spook_msg(self, author, channel, candies): + async def send_spook_msg(self, author: discord.Member, channel: discord.TextChannel, candies: int) -> None: """Send a spooky message.""" - e = discord.Embed(colour=author.colour) e.set_author(name="Ghosts and Ghouls and Jack o' lanterns at night; " f"I took {candies} candies and quickly took flight.") await channel.send(embed=e) - def save_to_json(self): + def save_to_json(self) -> None: """Save JSON to a local file.""" - with open(json_location, 'w') as outfile: json.dump(self.candy_json, outfile) @commands.command() - async def candy(self, ctx): + async def candy(self, ctx: commands.Context) -> None: """Get the candy leaderboard and save to JSON.""" - - # use run_in_executor to prevent blocking + # Use run_in_executor to prevent blocking thing = functools.partial(self.save_to_json) await self.bot.loop.run_in_executor(None, thing) @@ -223,8 +215,7 @@ class CandyCollection(commands.Cog): await ctx.send(embed=e) -def setup(bot): +def setup(bot: commands.Bot) -> None: """Candy Collection game Cog load.""" - bot.add_cog(CandyCollection(bot)) log.info("CandyCollection cog loaded") diff --git a/bot/seasons/halloween/hacktoberstats.py b/bot/seasons/halloween/hacktoberstats.py index 42623669..035eafbc 100644 --- a/bot/seasons/halloween/hacktoberstats.py +++ b/bot/seasons/halloween/hacktoberstats.py @@ -1,32 +1,38 @@ import json import logging import re -import typing from collections import Counter from datetime import datetime from pathlib import Path +from typing import List, Tuple import aiohttp import discord from discord.ext import commands +from bot.constants import Channels, WHITELISTED_CHANNELS +from bot.decorators import override_in_channel +from bot.utils.persist import make_persistent + + log = logging.getLogger(__name__) +CURRENT_YEAR = datetime.now().year # Used to construct GH API query +PRS_FOR_SHIRT = 4 # Minimum number of PRs before a shirt is awarded +HACKTOBER_WHITELIST = WHITELISTED_CHANNELS + (Channels.hacktoberfest_2019,) + class HacktoberStats(commands.Cog): """Hacktoberfest statistics Cog.""" - def __init__(self, bot): + def __init__(self, bot: commands.Bot): self.bot = bot - self.link_json = Path("bot", "resources", "github_links.json") + self.link_json = make_persistent(Path("bot", "resources", "halloween", "github_links.json")) self.linked_accounts = self.load_linked_users() - @commands.group( - name='hacktoberstats', - aliases=('hackstats',), - invoke_without_command=True - ) - async def hacktoberstats_group(self, ctx: commands.Context, github_username: str = None): + @commands.group(name="hacktoberstats", aliases=("hackstats",), invoke_without_command=True) + @override_in_channel(HACKTOBER_WHITELIST) + async def hacktoberstats_group(self, ctx: commands.Context, github_username: str = None) -> None: """ Display an embed for a user's Hacktoberfest contributions. @@ -34,7 +40,6 @@ class HacktoberStats(commands.Cog): linked their Discord name to GitHub using .stats link. If invoked with a github_username, get that user's contributions """ - if not github_username: author_id, author_mention = HacktoberStats._author_mention_from_context(ctx) @@ -44,8 +49,8 @@ class HacktoberStats(commands.Cog): else: msg = ( f"{author_mention}, you have not linked a GitHub account\n\n" - f"You can link your GitHub account using:\n```{ctx.prefix}stats link github_username```\n" - f"Or query GitHub stats directly using:\n```{ctx.prefix}stats github_username```" + f"You can link your GitHub account using:\n```{ctx.prefix}hackstats link github_username```\n" + f"Or query GitHub stats directly using:\n```{ctx.prefix}hackstats github_username```" ) await ctx.send(msg) return @@ -53,7 +58,7 @@ class HacktoberStats(commands.Cog): await self.get_stats(ctx, github_username) @hacktoberstats_group.command(name="link") - async def link_user(self, ctx: commands.Context, github_username: str = None): + async def link_user(self, ctx: commands.Context, github_username: str = None) -> None: """ Link the invoking user's Github github_username to their Discord ID. @@ -65,7 +70,6 @@ class HacktoberStats(commands.Cog): } } """ - author_id, author_mention = HacktoberStats._author_mention_from_context(ctx) if github_username: if str(author_id) in self.linked_accounts.keys(): @@ -87,9 +91,8 @@ class HacktoberStats(commands.Cog): await ctx.send(f"{author_mention}, a GitHub username is required to link your account") @hacktoberstats_group.command(name="unlink") - async def unlink_user(self, ctx: commands.Context): + async def unlink_user(self, ctx: commands.Context) -> None: """Remove the invoking user's account link from the log.""" - author_id, author_mention = HacktoberStats._author_mention_from_context(ctx) stored_user = self.linked_accounts.pop(author_id, None) @@ -102,7 +105,7 @@ class HacktoberStats(commands.Cog): self.save_linked_users() - def load_linked_users(self) -> typing.Dict: + def load_linked_users(self) -> dict: """ Load list of linked users from local JSON file. @@ -114,7 +117,6 @@ class HacktoberStats(commands.Cog): } } """ - if self.link_json.exists(): logging.info(f"Loading linked GitHub accounts from '{self.link_json}'") with open(self.link_json, 'r') as fID: @@ -126,7 +128,7 @@ class HacktoberStats(commands.Cog): logging.info(f"Linked account log: '{self.link_json}' does not exist") return {} - def save_linked_users(self): + def save_linked_users(self) -> None: """ Save list of linked users to local JSON file. @@ -138,13 +140,12 @@ class HacktoberStats(commands.Cog): } } """ - logging.info(f"Saving linked_accounts to '{self.link_json}'") with open(self.link_json, 'w') as fID: json.dump(self.linked_accounts, fID, default=str) logging.info(f"linked_accounts saved to '{self.link_json}'") - async def get_stats(self, ctx: commands.Context, github_username: str): + async def get_stats(self, ctx: commands.Context, github_username: str) -> None: """ Query GitHub's API for PRs created by a GitHub user during the month of October. @@ -154,7 +155,6 @@ class HacktoberStats(commands.Cog): Otherwise, post a helpful error message """ - async with ctx.typing(): prs = await self.get_october_prs(github_username) @@ -164,19 +164,18 @@ class HacktoberStats(commands.Cog): else: await ctx.send(f"No October GitHub contributions found for '{github_username}'") - def build_embed(self, github_username: str, prs: typing.List[dict]) -> discord.Embed: + def build_embed(self, github_username: str, prs: List[dict]) -> discord.Embed: """Return a stats embed built from github_username's PRs.""" - logging.info(f"Building Hacktoberfest embed for GitHub user: '{github_username}'") pr_stats = self._summarize_prs(prs) n = pr_stats['n_prs'] - if n >= 5: + if n >= PRS_FOR_SHIRT: shirtstr = f"**{github_username} has earned a tshirt!**" - elif n == 4: + elif n == PRS_FOR_SHIRT - 1: shirtstr = f"**{github_username} is 1 PR away from a tshirt!**" else: - shirtstr = f"**{github_username} is {5 - n} PRs away from a tshirt!**" + shirtstr = f"**{github_username} is {PRS_FOR_SHIRT - n} PRs away from a tshirt!**" stats_embed = discord.Embed( title=f"{github_username}'s Hacktoberfest", @@ -193,7 +192,7 @@ class HacktoberStats(commands.Cog): stats_embed.set_author( name="Hacktoberfest", url="https://hacktoberfest.digitalocean.com", - icon_url="https://hacktoberfest.digitalocean.com/assets/logo-hacktoberfest.png" + icon_url="https://hacktoberfest.digitalocean.com/pretty_logo.png" ) stats_embed.add_field( name="Top 5 Repositories:", @@ -204,7 +203,7 @@ class HacktoberStats(commands.Cog): return stats_embed @staticmethod - async def get_october_prs(github_username: str) -> typing.List[dict]: + async def get_october_prs(github_username: str) -> List[dict]: """ Query GitHub's API for PRs created during the month of October by github_username. @@ -221,13 +220,12 @@ class HacktoberStats(commands.Cog): Otherwise, return None """ - logging.info(f"Generating Hacktoberfest PR query for GitHub user: '{github_username}'") base_url = "https://api.github.com/search/issues?q=" not_label = "invalid" action_type = "pr" is_query = f"public+author:{github_username}" - date_range = "2018-10-01..2018-10-31" + date_range = f"{CURRENT_YEAR}-10-01T00:00:00%2B14:00..{CURRENT_YEAR}-10-31T00:00:00-11:00" per_page = "300" query_url = ( f"{base_url}" @@ -238,7 +236,7 @@ class HacktoberStats(commands.Cog): f"&per_page={per_page}" ) - headers = {"user-agent": "Discord Python Hactoberbot"} + headers = {"user-agent": "Discord Python Hacktoberbot"} async with aiohttp.ClientSession() as session: async with session.get(query_url, headers=headers) as resp: jsonresp = await resp.json() @@ -278,12 +276,11 @@ class HacktoberStats(commands.Cog): V "python-discord/seasonalbot" """ - exp = r"https?:\/\/api.github.com\/repos\/([/\-\_\.\w]+)" return re.findall(exp, in_url)[0] @staticmethod - def _summarize_prs(prs: typing.List[dict]) -> typing.Dict: + def _summarize_prs(prs: List[dict]) -> dict: """ Generate statistics from an input list of PR dictionaries, as output by get_october_prs. @@ -293,12 +290,11 @@ class HacktoberStats(commands.Cog): "top5": [(repo_shortname, ncontributions), ...] } """ - contributed_repos = [pr["repo_shortname"] for pr in prs] return {"n_prs": len(prs), "top5": Counter(contributed_repos).most_common(5)} @staticmethod - def _build_top5str(stats: typing.List[tuple]) -> str: + def _build_top5str(stats: List[tuple]) -> str: """ Build a string from the Top 5 contributions that is compatible with a discord.Embed field. @@ -309,7 +305,6 @@ class HacktoberStats(commands.Cog): n contribution(s) to [shortname](url) ... """ - baseURL = "https://www.github.com/" contributionstrs = [] for repo in stats['top5']: @@ -321,24 +316,21 @@ class HacktoberStats(commands.Cog): @staticmethod def _contributionator(n: int) -> str: """Return "contribution" or "contributions" based on the value of n.""" - if n == 1: return "contribution" else: return "contributions" @staticmethod - def _author_mention_from_context(ctx: commands.Context) -> typing.Tuple: + def _author_mention_from_context(ctx: commands.Context) -> Tuple: """Return stringified Message author ID and mentionable string from commands.Context.""" - author_id = str(ctx.message.author.id) author_mention = ctx.message.author.mention return author_id, author_mention -def setup(bot): +def setup(bot): # Noqa """Hacktoberstats Cog load.""" - bot.add_cog(HacktoberStats(bot)) log.info("HacktoberStats cog loaded") diff --git a/bot/seasons/halloween/halloween_facts.py b/bot/seasons/halloween/halloween_facts.py index ad9aa716..f8610bd3 100644 --- a/bot/seasons/halloween/halloween_facts.py +++ b/bot/seasons/halloween/halloween_facts.py @@ -3,6 +3,7 @@ import logging import random from datetime import timedelta from pathlib import Path +from typing import Tuple import discord from discord.ext import commands @@ -28,45 +29,40 @@ INTERVAL = timedelta(hours=6).total_seconds() class HalloweenFacts(commands.Cog): """A Cog for displaying interesting facts about Halloween.""" - def __init__(self, bot): + def __init__(self, bot: commands.Bot): self.bot = bot - with open(Path("bot", "resources", "halloween", "halloween_facts.json"), "r") as file: + 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): + async def on_ready(self) -> None: """Get event Channel object and initialize fact task loop.""" - self.channel = self.bot.get_channel(Channels.seasonalbot_chat) self.bot.loop.create_task(self._fact_publisher_task()) - def random_fact(self): + def random_fact(self) -> Tuple[int, str]: """Return a random fact from the loaded facts.""" - return random.choice(self.facts) @commands.command(name="spookyfact", aliases=("halloweenfact",), brief="Get the most recent Halloween fact") - async def get_random_fact(self, ctx): + async def get_random_fact(self, ctx: commands.Context) -> None: """Reply with the most recent Halloween fact.""" - index, fact = self.random_fact() embed = self._build_embed(index, fact) await ctx.send(embed=embed) @staticmethod - def _build_embed(index, fact): + def _build_embed(index: int, fact: str) -> discord.Embed: """Builds a Discord embed from the given fact and its index.""" - emoji = random.choice(SPOOKY_EMOJIS) title = f"{emoji} Halloween Fact #{index + 1}" return discord.Embed(title=title, description=fact, color=PUMPKIN_ORANGE) -def setup(bot): +def setup(bot: commands.Bot) -> None: """Halloween facts Cog load.""" - bot.add_cog(HalloweenFacts(bot)) log.info("HalloweenFacts cog loaded") diff --git a/bot/seasons/halloween/halloweenify.py b/bot/seasons/halloween/halloweenify.py index ce057889..dfcc2b1e 100644 --- a/bot/seasons/halloween/halloweenify.py +++ b/bot/seasons/halloween/halloweenify.py @@ -13,32 +13,31 @@ log = logging.getLogger(__name__) class Halloweenify(commands.Cog): """A cog to change a invokers nickname to a spooky one!""" - def __init__(self, bot): + def __init__(self, bot: commands.Bot): self.bot = bot @commands.cooldown(1, 300, BucketType.user) @commands.command() - async def halloweenify(self, ctx): - """Change your nickname into a much spookier one.""" - + async def halloweenify(self, ctx: commands.Context) -> None: + """Change your nickname into a much spookier one!""" async with ctx.typing(): - with open(Path('bot', 'resources', 'halloween', 'halloweenify.json'), 'r') as f: + with open(Path("bot/resources/halloween/halloweenify.json"), "r") as f: data = load(f) # Choose a random character from our list we loaded above and set apart the nickname and image url. - character = choice(data['characters']) + character = choice(data["characters"]) nickname = ''.join([nickname for nickname in character]) image = ''.join([character[nickname] for nickname in character]) # Build up a Embed embed = discord.Embed() embed.colour = discord.Colour.dark_orange() - embed.title = 'Not spooky enough?' + embed.title = "Not spooky enough?" embed.description = ( - f'**{ctx.author.display_name}** wasn\'t spooky enough for you? That\'s understandable, ' - f'{ctx.author.display_name} isn\'t scary at all! ' - 'Let me think of something better. Hmm... I got it!\n\n ' - f'Your new nickname will be: \n :ghost: **{nickname}** :jack_o_lantern:' + f"**{ctx.author.display_name}** wasn\'t spooky enough for you? That\'s understandable, " + f"{ctx.author.display_name} isn\'t scary at all! " + "Let me think of something better. Hmm... I got it!\n\n " + f"Your new nickname will be: \n :ghost: **{nickname}** :jack_o_lantern:" ) embed.set_image(url=image) @@ -47,8 +46,7 @@ class Halloweenify(commands.Cog): await ctx.send(embed=embed) -def setup(bot): +def setup(bot: commands.Bot) -> None: """Halloweenify Cog load.""" - bot.add_cog(Halloweenify(bot)) log.info("Halloweenify cog loaded") diff --git a/bot/seasons/halloween/monstersurvey.py b/bot/seasons/halloween/monstersurvey.py index 2ae98f6e..12e1d022 100644 --- a/bot/seasons/halloween/monstersurvey.py +++ b/bot/seasons/halloween/monstersurvey.py @@ -25,30 +25,23 @@ class MonsterSurvey(Cog): def __init__(self, bot: Bot): """Initializes values for the bot to use within the voting commands.""" - self.bot = bot self.registry_location = os.path.join(os.getcwd(), 'bot', 'resources', 'halloween', 'monstersurvey.json') with open(self.registry_location, 'r') as jason: self.voter_registry = json.load(jason) - def json_write(self): + def json_write(self) -> None: """Write voting results to a local JSON file.""" - log.info("Saved Monster Survey Results") with open(self.registry_location, 'w') as jason: json.dump(self.voter_registry, jason, indent=2) - def cast_vote(self, id: int, monster: str): + def cast_vote(self, id: int, monster: str) -> None: """ Cast a user's vote for the specified monster. If the user has already voted, their existing vote is removed. - - :param id: The id of the person voting - :param monster: the string key of the json that represents a monster - :return: None """ - vr = self.voter_registry for m in vr.keys(): if id not in vr[m]['votes'] and m == monster: @@ -57,9 +50,8 @@ class MonsterSurvey(Cog): if id in vr[m]['votes'] and m != monster: vr[m]['votes'].remove(id) - def get_name_by_leaderboard_index(self, n): + def get_name_by_leaderboard_index(self, n: int) -> str: """Return the monster at the specified leaderboard index.""" - n = n - 1 vr = self.voter_registry top = sorted(vr, key=lambda k: len(vr[k]['votes']), reverse=True) @@ -68,11 +60,10 @@ class MonsterSurvey(Cog): @commands.group( name='monster', - aliases=('ms',) + aliases=('mon',) ) - async def monster_group(self, ctx: Context): + async def monster_group(self, ctx: Context) -> None: """The base voting command. If nothing is called, then it will return an embed.""" - if ctx.invoked_subcommand is None: async with ctx.typing(): default_embed = Embed( @@ -101,13 +92,12 @@ class MonsterSurvey(Cog): @monster_group.command( name='vote' ) - async def monster_vote(self, ctx: Context, name=None): + async def monster_vote(self, ctx: Context, name: str = None) -> None: """ Cast a vote for a particular monster. Displays a list of monsters that can be voted for if one is not specified. """ - if name is None: await ctx.invoke(self.monster_leaderboard) return @@ -153,15 +143,8 @@ class MonsterSurvey(Cog): @monster_group.command( name='show' ) - async def monster_show(self, ctx: Context, name=None): - """ - Shows the named monster. If one is not named, it sends the default voting embed instead. - - :param ctx: - :param name: - :return: - """ - + async def monster_show(self, ctx: Context, name: str = None) -> None: + """Shows the named monster. If one is not named, it sends the default voting embed instead.""" if name is None: await ctx.invoke(self.monster_leaderboard) return @@ -191,14 +174,8 @@ class MonsterSurvey(Cog): name='leaderboard', aliases=('lb',) ) - async def monster_leaderboard(self, ctx: Context): - """ - Shows the current standings. - - :param ctx: - :return: - """ - + async def monster_leaderboard(self, ctx: Context) -> None: + """Shows the current standings.""" async with ctx.typing(): vr = self.voter_registry top = sorted(vr, key=lambda k: len(vr[k]['votes']), reverse=True) @@ -223,8 +200,7 @@ class MonsterSurvey(Cog): await ctx.send(embed=embed) -def setup(bot): +def setup(bot: Bot) -> None: """Monster survey Cog load.""" - bot.add_cog(MonsterSurvey(bot)) log.info("MonsterSurvey cog loaded") diff --git a/bot/seasons/halloween/scarymovie.py b/bot/seasons/halloween/scarymovie.py index 3878ef7f..3823a3e4 100644 --- a/bot/seasons/halloween/scarymovie.py +++ b/bot/seasons/halloween/scarymovie.py @@ -16,13 +16,12 @@ TMDB_TOKEN = environ.get('TMDB_TOKEN') class ScaryMovie(commands.Cog): """Selects a random scary movie and embeds info into Discord chat.""" - def __init__(self, bot): + def __init__(self, bot: commands.Bot): self.bot = bot @commands.command(name='scarymovie', alias=['smovie']) - async def random_movie(self, ctx): + async def random_movie(self, ctx: commands.Context) -> None: """Randomly select a scary movie and display information about it.""" - async with ctx.typing(): selection = await self.select_movie() movie_details = await self.format_metadata(selection) @@ -30,9 +29,8 @@ class ScaryMovie(commands.Cog): await ctx.send(embed=movie_details) @staticmethod - async def select_movie(): - """Selects a random movie and returns a json of movie details from TMDb.""" - + async def select_movie() -> dict: + """Selects a random movie and returns a JSON of movie details from TMDb.""" url = 'https://api.themoviedb.org/4/discover/movie' params = { 'with_genres': '27', @@ -64,9 +62,8 @@ class ScaryMovie(commands.Cog): return await selection.json() @staticmethod - async def format_metadata(movie): - """Formats raw TMDb data to be embedded in discord chat.""" - + async def format_metadata(movie: dict) -> Embed: + """Formats raw TMDb data to be embedded in Discord chat.""" # Build the relevant URLs. movie_id = movie.get("id") poster_path = movie.get("poster_path") @@ -129,8 +126,7 @@ class ScaryMovie(commands.Cog): return embed -def setup(bot): +def setup(bot: commands.Bot) -> None: """Scary movie Cog load.""" - bot.add_cog(ScaryMovie(bot)) log.info("ScaryMovie cog loaded") diff --git a/bot/seasons/halloween/spookyavatar.py b/bot/seasons/halloween/spookyavatar.py index 2cc81da8..268de3fb 100644 --- a/bot/seasons/halloween/spookyavatar.py +++ b/bot/seasons/halloween/spookyavatar.py @@ -15,21 +15,19 @@ log = logging.getLogger(__name__) class SpookyAvatar(commands.Cog): """A cog that spookifies an avatar.""" - def __init__(self, bot): + def __init__(self, bot: commands.Bot): self.bot = bot - async def get(self, url): - """Returns the contents of the supplied url.""" - + async def get(self, url: str) -> bytes: + """Returns the contents of the supplied URL.""" async with aiohttp.ClientSession() as session: async with session.get(url) as resp: return await resp.read() @commands.command(name='savatar', aliases=('spookyavatar', 'spookify'), brief='Spookify an user\'s avatar.') - async def spooky_avatar(self, ctx, user: discord.Member = None): + async def spooky_avatar(self, ctx: commands.Context, user: discord.Member = None) -> None: """A command to print the user's spookified avatar.""" - if user is None: user = ctx.message.author @@ -49,8 +47,7 @@ class SpookyAvatar(commands.Cog): os.remove(str(ctx.message.id)+'.png') -def setup(bot): +def setup(bot: commands.Bot) -> None: """Spooky avatar Cog load.""" - bot.add_cog(SpookyAvatar(bot)) log.info("SpookyAvatar cog loaded") diff --git a/bot/seasons/halloween/spookygif.py b/bot/seasons/halloween/spookygif.py index 37d46c01..818de8cd 100644 --- a/bot/seasons/halloween/spookygif.py +++ b/bot/seasons/halloween/spookygif.py @@ -12,13 +12,12 @@ log = logging.getLogger(__name__) class SpookyGif(commands.Cog): """A cog to fetch a random spooky gif from the web!""" - def __init__(self, bot): + def __init__(self, bot: commands.Bot): self.bot = bot @commands.command(name="spookygif", aliases=("sgif", "scarygif")) - async def spookygif(self, ctx): + async def spookygif(self, ctx: commands.Context) -> None: """Fetches a random gif from the GIPHY API and responds with it.""" - async with ctx.typing(): async with aiohttp.ClientSession() as session: params = {'api_key': Tokens.giphy, 'tag': 'halloween', 'rating': 'g'} @@ -34,8 +33,7 @@ class SpookyGif(commands.Cog): await ctx.send(embed=embed) -def setup(bot): +def setup(bot: commands.Bot) -> None: """Spooky GIF Cog load.""" - bot.add_cog(SpookyGif(bot)) log.info("SpookyGif cog loaded") diff --git a/bot/seasons/halloween/spookyrating.py b/bot/seasons/halloween/spookyrating.py index a9cfda9b..7f78f536 100644 --- a/bot/seasons/halloween/spookyrating.py +++ b/bot/seasons/halloween/spookyrating.py @@ -11,27 +11,26 @@ from bot.constants import Colours log = logging.getLogger(__name__) -with Path('bot', 'resources', 'halloween', 'spooky_rating.json').open() as file: +with Path("bot/resources/halloween/spooky_rating.json").open() as file: SPOOKY_DATA = json.load(file) SPOOKY_DATA = sorted((int(key), value) for key, value in SPOOKY_DATA.items()) class SpookyRating(commands.Cog): - """A cog for calculating one's spooky rating""" + """A cog for calculating one's spooky rating.""" - def __init__(self, bot): + def __init__(self, bot: commands.Bot): self.bot = bot self.local_random = random.Random() @commands.command() @commands.cooldown(rate=1, per=5, type=commands.BucketType.user) - async def spookyrating(self, ctx, who: discord.Member = None): + async def spookyrating(self, ctx: commands.Context, who: discord.Member = None) -> None: """ Calculates the spooky rating of someone. Any user will always yield the same result, no matter who calls the command """ - if who is None: who = ctx.author @@ -62,7 +61,7 @@ class SpookyRating(commands.Cog): await ctx.send(embed=embed) -def setup(bot): - """Cog load.""" +def setup(bot: commands.Bot) -> None: + """Spooky Rating Cog load.""" bot.add_cog(SpookyRating(bot)) log.info("SpookyRating cog loaded") diff --git a/bot/seasons/halloween/spookyreact.py b/bot/seasons/halloween/spookyreact.py index 9b14507a..90b1254d 100644 --- a/bot/seasons/halloween/spookyreact.py +++ b/bot/seasons/halloween/spookyreact.py @@ -2,7 +2,7 @@ import logging import re import discord -from discord.ext.commands import Cog +from discord.ext.commands import Bot, Cog log = logging.getLogger(__name__) @@ -20,11 +20,11 @@ SPOOKY_TRIGGERS = { class SpookyReact(Cog): """A cog that makes the bot react to message triggers.""" - def __init__(self, bot): + def __init__(self, bot: Bot): self.bot = bot @Cog.listener() - async def on_message(self, ctx: discord.Message): + async def on_message(self, ctx: discord.Message) -> None: """ A command to send the seasonalbot github project. @@ -32,7 +32,6 @@ class SpookyReact(Cog): Seasonalbot's own messages are ignored """ - for trigger in SPOOKY_TRIGGERS.keys(): trigger_test = re.search(SPOOKY_TRIGGERS[trigger][0], ctx.content.lower()) if trigger_test: @@ -52,7 +51,6 @@ class SpookyReact(Cog): * author is the bot * prefix is not None """ - # Check for self reaction if ctx.author == self.bot.user: logging.debug(f"Ignoring reactions on self message. Message ID: {ctx.id}") @@ -68,8 +66,7 @@ class SpookyReact(Cog): return False -def setup(bot): +def setup(bot: Bot) -> None: """Spooky reaction Cog load.""" - bot.add_cog(SpookyReact(bot)) log.info("SpookyReact cog loaded") diff --git a/bot/seasons/halloween/spookysound.py b/bot/seasons/halloween/spookysound.py index 7c4d8113..e0676d0a 100644 --- a/bot/seasons/halloween/spookysound.py +++ b/bot/seasons/halloween/spookysound.py @@ -13,20 +13,19 @@ log = logging.getLogger(__name__) class SpookySound(commands.Cog): """A cog that plays a spooky sound in a voice channel on command.""" - def __init__(self, bot): + def __init__(self, bot: commands.Bot): self.bot = bot - self.sound_files = list(Path("bot", "resources", "halloween", "spookysounds").glob("*.mp3")) + self.sound_files = list(Path("bot/resources/halloween/spookysounds").glob("*.mp3")) self.channel = None @commands.cooldown(rate=1, per=1) @commands.command(brief="Play a spooky sound, restricted to once per 2 mins") - async def spookysound(self, ctx): + async def spookysound(self, ctx: commands.Context) -> None: """ Connect to the Hacktoberbot voice channel, play a random spooky sound, then disconnect. Cannot be used more than once in 2 minutes. """ - if not self.channel: await self.bot.wait_until_ready() self.channel = self.bot.get_channel(Hacktoberfest.voice_id) @@ -38,13 +37,12 @@ class SpookySound(commands.Cog): voice.play(src, after=lambda e: self.bot.loop.create_task(self.disconnect(voice))) @staticmethod - async def disconnect(voice): + async def disconnect(voice: discord.VoiceClient) -> None: """Helper method to disconnect a given voice client.""" await voice.disconnect() -def setup(bot): +def setup(bot: commands.Bot) -> None: """Spooky sound Cog load.""" - bot.add_cog(SpookySound(bot)) log.info("SpookySound cog loaded") diff --git a/bot/seasons/halloween/timeleft.py b/bot/seasons/halloween/timeleft.py index 3ea2d9ad..77767baa 100644 --- a/bot/seasons/halloween/timeleft.py +++ b/bot/seasons/halloween/timeleft.py @@ -1,5 +1,6 @@ import logging from datetime import datetime +from typing import Tuple from discord.ext import commands @@ -9,19 +10,17 @@ log = logging.getLogger(__name__) class TimeLeft(commands.Cog): """A Cog that tells you how long left until Hacktober is over!""" - def __init__(self, bot): + def __init__(self, bot: commands.Bot): self.bot = bot @staticmethod - def in_october(): + def in_october() -> bool: """Return True if the current month is October.""" - return datetime.utcnow().month == 10 @staticmethod - def load_date(): + def load_date() -> Tuple[int, datetime, datetime]: """Return of a tuple of the current time and the end and start times of the next October.""" - now = datetime.utcnow() year = now.year if now.month > 10: @@ -31,14 +30,13 @@ class TimeLeft(commands.Cog): return now, end, start @commands.command() - async def timeleft(self, ctx): + async def timeleft(self, ctx: commands.Context) -> None: """ Calculates the time left until the end of Hacktober. Whilst in October, displays the days, hours and minutes left. Only displays the days left until the beginning and end whilst in a different month """ - now, end, start = self.load_date() diff = end - now days, seconds = diff.days, diff.seconds @@ -56,8 +54,7 @@ class TimeLeft(commands.Cog): ) -def setup(bot): +def setup(bot: commands.Bot) -> None: """Cog load.""" - bot.add_cog(TimeLeft(bot)) log.info("TimeLeft cog loaded") diff --git a/bot/seasons/pride/__init__.py b/bot/seasons/pride/__init__.py index cbd21ee2..75e90b2a 100644 --- a/bot/seasons/pride/__init__.py +++ b/bot/seasons/pride/__init__.py @@ -1,11 +1,24 @@ +from bot.constants import Colours from bot.seasons import SeasonBase class Pride(SeasonBase): """ - No matter your origin, identity or sexuality, we come together to celebrate each and everyone's individuality. + The month of June is a special month for us at Python Discord. - Feature contributions to ProudBot is encouraged to commemorate the history and challenges of the LGBTQ+ community. + 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 <#542272993192050698> 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" @@ -14,4 +27,10 @@ class Pride(SeasonBase): # Duration of season start_date = "01/06" - end_date = "30/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_anthem.py b/bot/seasons/pride/pride_anthem.py new file mode 100644 index 00000000..b0c6d34e --- /dev/null +++ b/bot/seasons/pride/pride_anthem.py @@ -0,0 +1,58 @@ +import json +import logging +import random +from pathlib import Path + +from discord.ext import commands + +log = logging.getLogger(__name__) + + +class PrideAnthem(commands.Cog): + """Embed a random youtube video for a gay anthem!""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.anthems = self.load_vids() + + def get_video(self, genre: str = None) -> dict: + """ + Picks a random anthem from the list. + + If `genre` is supplied, it will pick from videos attributed with that genre. + If none can be found, it will log this as well as provide that information to the user. + """ + if not genre: + return random.choice(self.anthems) + else: + songs = [song for song in self.anthems if genre.casefold() in song["genre"]] + try: + return random.choice(songs) + except IndexError: + log.info("No videos for that genre.") + + @staticmethod + def load_vids() -> list: + """Loads a list of videos from the resources folder as dictionaries.""" + with open(Path("bot/resources/pride/anthems.json"), "r", encoding="utf-8") as f: + anthems = json.load(f) + return anthems + + @commands.command(name="prideanthem", aliases=["anthem", "pridesong"]) + async def prideanthem(self, ctx: commands.Context, genre: str = None) -> None: + """ + Sends a message with a video of a random pride anthem. + + If `genre` is supplied, it will select from that genre only. + """ + anthem = self.get_video(genre) + if anthem: + await ctx.send(anthem["url"]) + else: + await ctx.send("I couldn't find a video, sorry!") + + +def setup(bot: commands.Bot) -> None: + """Cog loader for pride anthem.""" + bot.add_cog(PrideAnthem(bot)) + log.info("Pride anthems cog loaded!") diff --git a/bot/seasons/pride/pride_avatar.py b/bot/seasons/pride/pride_avatar.py new file mode 100644 index 00000000..85e49d5c --- /dev/null +++ b/bot/seasons/pride/pride_avatar.py @@ -0,0 +1,145 @@ +import logging +from io import BytesIO +from pathlib import Path + +import discord +from PIL import Image, ImageDraw +from discord.ext import commands + +from bot.constants import Colours + +log = logging.getLogger(__name__) + +OPTIONS = { + "agender": "agender", + "androgyne": "androgyne", + "androgynous": "androgyne", + "aromantic": "aromantic", + "aro": "aromantic", + "ace": "asexual", + "asexual": "asexual", + "bigender": "bigender", + "bisexual": "bisexual", + "bi": "bisexual", + "demiboy": "demiboy", + "demigirl": "demigirl", + "demi": "demisexual", + "demisexual": "demisexual", + "gay": "gay", + "lgbt": "gay", + "queer": "gay", + "homosexual": "gay", + "fluid": "genderfluid", + "genderfluid": "genderfluid", + "genderqueer": "genderqueer", + "intersex": "intersex", + "lesbian": "lesbian", + "non-binary": "nonbinary", + "enby": "nonbinary", + "nb": "nonbinary", + "nonbinary": "nonbinary", + "omnisexual": "omnisexual", + "omni": "omnisexual", + "pansexual": "pansexual", + "pan": "pansexual", + "pangender": "pangender", + "poly": "polysexual", + "polysexual": "polysexual", + "polyamory": "polyamory", + "polyamorous": "polyamory", + "transgender": "transgender", + "trans": "transgender", + "trigender": "trigender" +} + + +class PrideAvatar(commands.Cog): + """Put an LGBT spin on your avatar!""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @staticmethod + def crop_avatar(avatar: Image) -> Image: + """This crops the avatar into a circle.""" + mask = Image.new("L", avatar.size, 0) + draw = ImageDraw.Draw(mask) + draw.ellipse((0, 0) + avatar.size, fill=255) + avatar.putalpha(mask) + return avatar + + @staticmethod + def crop_ring(ring: Image, px: int) -> Image: + """This crops the ring into a circle.""" + mask = Image.new("L", ring.size, 0) + draw = ImageDraw.Draw(mask) + draw.ellipse((0, 0) + ring.size, fill=255) + draw.ellipse((px, px, 1024-px, 1024-px), fill=0) + ring.putalpha(mask) + return ring + + @commands.group(aliases=["avatarpride", "pridepfp", "prideprofile"], invoke_without_command=True) + async def prideavatar(self, ctx: commands.Context, option: str = "lgbt", pixels: int = 64) -> None: + """ + This surrounds an avatar with a border of a specified LGBT flag. + + This defaults to the LGBT rainbow flag if none is given. + The amount of pixels can be given which determines the thickness of the flag border. + This has a maximum of 512px and defaults to a 64px border. + The full image is 1024x1024. + """ + pixels = 0 if pixels < 0 else 512 if pixels > 512 else pixels + + option = option.lower() + + if option not in OPTIONS.keys(): + return await ctx.send("I don't have that flag!") + + flag = OPTIONS[option] + + async with ctx.typing(): + + # Get avatar bytes + image_bytes = await ctx.author.avatar_url.read() + avatar = Image.open(BytesIO(image_bytes)) + avatar = avatar.convert("RGBA").resize((1024, 1024)) + + avatar = self.crop_avatar(avatar) + + ring = Image.open(Path(f"bot/resources/pride/flags/{flag}.png")).resize((1024, 1024)) + ring = ring.convert("RGBA") + ring = self.crop_ring(ring, pixels) + + avatar.alpha_composite(ring, (0, 0)) + bufferedio = BytesIO() + avatar.save(bufferedio, format="PNG") + bufferedio.seek(0) + + file = discord.File(bufferedio, filename="pride_avatar.png") # Creates file to be used in embed + embed = discord.Embed( + name="Your Lovely Pride Avatar", + description=f"Here is your lovely avatar, surrounded by\n a beautiful {option} flag. Enjoy :D" + ) + embed.set_image(url="attachment://pride_avatar.png") + embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) + + await ctx.send(file=file, embed=embed) + + @prideavatar.command() + async def flags(self, ctx: commands.Context) -> None: + """This lists the flags that can be used with the prideavatar command.""" + choices = sorted(set(OPTIONS.values())) + options = "• " + "\n• ".join(choices) + embed = discord.Embed( + title="I have the following flags:", + description=options, + colour=Colours.soft_red + ) + + await ctx.send(embed=embed) + + +def setup(bot: commands.Bot) -> None: + """Cog load.""" + bot.add_cog(PrideAvatar(bot)) + log.info("PrideAvatar cog loaded") 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() diff --git a/bot/seasons/valentines/__init__.py b/bot/seasons/valentines/__init__.py index e3e04421..6e5d16f7 100644 --- a/bot/seasons/valentines/__init__.py +++ b/bot/seasons/valentines/__init__.py @@ -17,4 +17,6 @@ class Valentines(SeasonBase): end_date = "01/03" colour = Colours.pink - icon = "/logos/logo_seasonal/valentines/loved_up.png" + icon = ( + "/logos/logo_seasonal/valentines/loved_up.png", + ) diff --git a/bot/seasons/valentines/be_my_valentine.py b/bot/seasons/valentines/be_my_valentine.py index 8340d7fa..a073e1bd 100644 --- a/bot/seasons/valentines/be_my_valentine.py +++ b/bot/seasons/valentines/be_my_valentine.py @@ -1,8 +1,8 @@ import logging import random -import typing from json import load from pathlib import Path +from typing import Optional, Tuple import discord from discord.ext import commands @@ -18,21 +18,20 @@ HEART_EMOJIS = [":heart:", ":gift_heart:", ":revolving_hearts:", ":sparkling_hea class BeMyValentine(commands.Cog): """A cog that sends Valentines to other users!""" - def __init__(self, bot): + def __init__(self, bot: commands.Bot): self.bot = bot self.valentines = self.load_json() @staticmethod - def load_json(): + def load_json() -> dict: """Load Valentines messages from the static resources.""" - - p = Path('bot', 'resources', 'valentines', 'bemyvalentine_valentines.json') + p = Path("bot/resources/valentines/bemyvalentine_valentines.json") with p.open() as json_data: valentines = load(json_data) return valentines @commands.group(name="lovefest", invoke_without_command=True) - async def lovefest_role(self, ctx): + async def lovefest_role(self, ctx: commands.Context) -> None: """ Subscribe or unsubscribe from the lovefest role. @@ -41,13 +40,11 @@ class BeMyValentine(commands.Cog): 1) use the command \".lovefest sub\" to get the lovefest role. 2) use the command \".lovefest unsub\" to get rid of the lovefest role. """ - await ctx.send_help(ctx.command) @lovefest_role.command(name="sub") - async def add_role(self, ctx): + async def add_role(self, ctx: commands.Context) -> None: """Adds the lovefest role.""" - user = ctx.author role = discord.utils.get(ctx.guild.roles, id=Lovefest.role_id) if Lovefest.role_id not in [role.id for role in ctx.message.author.roles]: @@ -57,9 +54,8 @@ class BeMyValentine(commands.Cog): await ctx.send("You already have the role !") @lovefest_role.command(name="unsub") - async def remove_role(self, ctx): + async def remove_role(self, ctx: commands.Context) -> None: """Removes the lovefest role.""" - user = ctx.author role = discord.utils.get(ctx.guild.roles, id=Lovefest.role_id) if Lovefest.role_id not in [role.id for role in ctx.message.author.roles]: @@ -70,7 +66,9 @@ class BeMyValentine(commands.Cog): @commands.cooldown(1, 1800, BucketType.user) @commands.group(name='bemyvalentine', invoke_without_command=True) - async def send_valentine(self, ctx, user: typing.Optional[discord.Member] = None, *, valentine_type=None): + async def send_valentine( + self, ctx: commands.Context, user: Optional[discord.Member] = None, *, valentine_type: str = None + ) -> None: """ Send a valentine to user, if specified, or to a random user with the lovefest role. @@ -82,7 +80,6 @@ class BeMyValentine(commands.Cog): example: .bemyvalentine Iceman Hey I love you, wanna hang around ? (sends the custom message to Iceman) NOTE : AVOID TAGGING THE USER MOST OF THE TIMES.JUST TRIM THE '@' when using this command. """ - if ctx.guild is None: # This command should only be used in the server msg = "You are supposed to use this command in the server." @@ -117,7 +114,9 @@ class BeMyValentine(commands.Cog): @commands.cooldown(1, 1800, BucketType.user) @send_valentine.command(name='secret') - async def anonymous(self, ctx, user: typing.Optional[discord.Member] = None, *, valentine_type=None): + async def anonymous( + self, ctx: commands.Context, user: Optional[discord.Member] = None, *, valentine_type: str = None + ) -> None: """ Send an anonymous Valentine via DM to to a user, if specified, or to a random with the lovefest role. @@ -132,7 +131,6 @@ class BeMyValentine(commands.Cog): example : .bemyvalentine secret Iceman#6508 Hey I love you, wanna hang around ? (sends the custom message to Iceman in DM making you anonymous) """ - if ctx.guild is not None: # This command is only DM specific msg = "You are not supposed to use this command in the server, DM the command to the bot." @@ -170,9 +168,8 @@ class BeMyValentine(commands.Cog): else: await ctx.author.send(f"Your message has been sent to {user}") - def valentine_check(self, valentine_type): + def valentine_check(self, valentine_type: str) -> Tuple[str, str]: """Return the appropriate Valentine type & title based on the invoking user's input.""" - if valentine_type is None: valentine, title = self.random_valentine() @@ -191,32 +188,26 @@ class BeMyValentine(commands.Cog): return valentine, title @staticmethod - def random_user(author, members): + def random_user(author: discord.Member, members: discord.Member) -> None: """ Picks a random member from the list provided in `members`. The invoking author is ignored. - - :param author: member who invoked the command - :param members: list of discord.Member objects """ - if author in members: members.remove(author) return random.choice(members) if members else None @staticmethod - def random_emoji(): + def random_emoji() -> Tuple[str, str]: """Return two random emoji from the module-defined constants.""" - EMOJI_1 = random.choice(HEART_EMOJIS) EMOJI_2 = random.choice(HEART_EMOJIS) return EMOJI_1, EMOJI_2 - def random_valentine(self): + def random_valentine(self) -> Tuple[str, str]: """Grabs a random poem or a compliment (any message).""" - valentine_poem = random.choice(self.valentines['valentine_poems']) valentine_compliment = random.choice(self.valentines['valentine_compliments']) random_valentine = random.choice([valentine_compliment, valentine_poem]) @@ -226,21 +217,18 @@ class BeMyValentine(commands.Cog): title = 'A compliment for ' return random_valentine, title - def valentine_poem(self): + def valentine_poem(self) -> str: """Grabs a random poem.""" - valentine_poem = random.choice(self.valentines['valentine_poems']) return valentine_poem - def valentine_compliment(self): + def valentine_compliment(self) -> str: """Grabs a random compliment.""" - valentine_compliment = random.choice(self.valentines['valentine_compliments']) return valentine_compliment -def setup(bot): +def setup(bot: commands.Bot) -> None: """Be my Valentine Cog load.""" - bot.add_cog(BeMyValentine(bot)) log.info("BeMyValentine cog loaded") diff --git a/bot/seasons/valentines/lovecalculator.py b/bot/seasons/valentines/lovecalculator.py index cd684f9d..03d3d7d5 100644 --- a/bot/seasons/valentines/lovecalculator.py +++ b/bot/seasons/valentines/lovecalculator.py @@ -15,7 +15,7 @@ from bot.constants import Roles log = logging.getLogger(__name__) -with Path('bot', 'resources', 'valentines', 'love_matches.json').open() as file: +with Path("bot/resources/valentines/love_matches.json").open() as file: LOVE_DATA = json.load(file) LOVE_DATA = sorted((int(key), value) for key, value in LOVE_DATA.items()) @@ -23,12 +23,12 @@ with Path('bot', 'resources', 'valentines', 'love_matches.json').open() as file: class LoveCalculator(Cog): """A cog for calculating the love between two people.""" - def __init__(self, bot): + def __init__(self, bot: commands.Bot): self.bot = bot @commands.command(aliases=('love_calculator', 'love_calc')) @commands.cooldown(rate=1, per=5, type=commands.BucketType.user) - async def love(self, ctx, who: Union[Member, str], whom: Union[Member, str] = None): + async def love(self, ctx: commands.Context, who: Union[Member, str], whom: Union[Member, str] = None) -> None: """ Tells you how much the two love each other. @@ -49,41 +49,39 @@ class LoveCalculator(Cog): If only one argument is provided, the subject will become one of the helpers at random. """ - if whom is None: staff = ctx.guild.get_role(Roles.helpers).members whom = random.choice(staff) - def normalize(arg): + def normalize(arg: Union[Member, str]) -> str: if isinstance(arg, Member): - # if we are given a member, return name#discrim without any extra changes + # If we are given a member, return name#discrim without any extra changes arg = str(arg) else: - # otherwise normalise case and remove any leading/trailing whitespace + # Otherwise normalise case and remove any leading/trailing whitespace arg = arg.strip().title() - # this has to be done manually to be applied to usernames + # This has to be done manually to be applied to usernames return clean_content(escape_markdown=True).convert(ctx, arg) who, whom = [await normalize(arg) for arg in (who, whom)] - # make sure user didn't provide something silly such as 10 spaces + # Make sure user didn't provide something silly such as 10 spaces if not (who and whom): raise BadArgument('Arguments be non-empty strings.') - # hash inputs to guarantee consistent results (hashing algorithm choice arbitrary) + # Hash inputs to guarantee consistent results (hashing algorithm choice arbitrary) # - # hashlib is used over the builtin hash() function - # to guarantee same result over multiple runtimes + # hashlib is used over the builtin hash() to guarantee same result over multiple runtimes m = hashlib.sha256(who.encode() + whom.encode()) - # mod 101 for [0, 100] + # Mod 101 for [0, 100] love_percent = sum(m.digest()) % 101 # We need the -1 due to how bisect returns the point # see the documentation for further detail # https://docs.python.org/3/library/bisect.html#bisect.bisect index = bisect.bisect(LOVE_DATA, (love_percent,)) - 1 - # we already have the nearest "fit" love level - # we only need the dict, so we can ditch the first element + # We already have the nearest "fit" love level + # We only need the dict, so we can ditch the first element _, data = LOVE_DATA[index] status = random.choice(data['titles']) @@ -100,8 +98,7 @@ class LoveCalculator(Cog): await ctx.send(embed=embed) -def setup(bot): +def setup(bot: commands.Bot) -> None: """Love calculator Cog load.""" - bot.add_cog(LoveCalculator(bot)) log.info("LoveCalculator cog loaded") diff --git a/bot/seasons/valentines/movie_generator.py b/bot/seasons/valentines/movie_generator.py index a09a563f..ce1d7d5b 100644 --- a/bot/seasons/valentines/movie_generator.py +++ b/bot/seasons/valentines/movie_generator.py @@ -12,16 +12,15 @@ log = logging.getLogger(__name__) class RomanceMovieFinder(commands.Cog): - """A cog that returns a random romance movie suggestion to a user.""" + """A Cog that returns a random romance movie suggestion to a user.""" - def __init__(self, bot): + def __init__(self, bot: commands.Bot): self.bot = bot @commands.command(name="romancemovie") - async def romance_movie(self, ctx): + async def romance_movie(self, ctx: commands.Context) -> None: """Randomly selects a romance movie and displays information about it.""" - - # selecting a random int to parse it to the page parameter + # Selecting a random int to parse it to the page parameter random_page = random.randint(0, 20) # TMDB api params params = { @@ -33,13 +32,13 @@ class RomanceMovieFinder(commands.Cog): "page": random_page, "with_genres": "10749" } - # the api request url + # The api request url request_url = "https://api.themoviedb.org/3/discover/movie?" + parse.urlencode(params) async with self.bot.http_session.get(request_url) as resp: - # trying to load the json file returned from the api + # Trying to load the json file returned from the api try: data = await resp.json() - # selecting random result from results object in the json file + # Selecting random result from results object in the json file selected_movie = random.choice(data["results"]) embed = discord.Embed( @@ -58,8 +57,7 @@ class RomanceMovieFinder(commands.Cog): await ctx.send(embed=embed) -def setup(bot): +def setup(bot: commands.Bot) -> None: """Romance movie Cog load.""" - bot.add_cog(RomanceMovieFinder(bot)) log.info("RomanceMovieFinder cog loaded") diff --git a/bot/seasons/valentines/myvalenstate.py b/bot/seasons/valentines/myvalenstate.py index 344f52f6..0256c39a 100644 --- a/bot/seasons/valentines/myvalenstate.py +++ b/bot/seasons/valentines/myvalenstate.py @@ -11,19 +11,18 @@ from bot.constants import Colours log = logging.getLogger(__name__) -with open(Path('bot', 'resources', 'valentines', 'valenstates.json'), 'r') as file: +with open(Path("bot/resources/valentines/valenstates.json"), "r") as file: STATES = json.load(file) class MyValenstate(commands.Cog): """A Cog to find your most likely Valentine's vacation destination.""" - def __init__(self, bot): + def __init__(self, bot: commands.Bot): self.bot = bot - def levenshtein(self, source, goal): + def levenshtein(self, source: str, goal: str) -> int: """Calculates the Levenshtein Distance between source and goal.""" - if len(source) < len(goal): return self.levenshtein(goal, source) if len(source) == 0: @@ -43,9 +42,8 @@ class MyValenstate(commands.Cog): return pre_row[-1] @commands.command() - async def myvalenstate(self, ctx, *, name=None): + async def myvalenstate(self, ctx: commands.Context, *, name: str = None) -> None: """Find the vacation spot(s) with the most matching characters to the invoking user.""" - eq_chars = collections.defaultdict(int) if name is None: author = ctx.message.author.name.lower().replace(' ', '') @@ -83,8 +81,7 @@ class MyValenstate(commands.Cog): await ctx.channel.send(embed=embed) -def setup(bot): +def setup(bot: commands.Bot) -> None: """Valenstate Cog load.""" - bot.add_cog(MyValenstate(bot)) log.info("MyValenstate cog loaded") diff --git a/bot/seasons/valentines/pickuplines.py b/bot/seasons/valentines/pickuplines.py index ad75c93f..8b2c9822 100644 --- a/bot/seasons/valentines/pickuplines.py +++ b/bot/seasons/valentines/pickuplines.py @@ -10,24 +10,23 @@ from bot.constants import Colours log = logging.getLogger(__name__) -with open(Path('bot', 'resources', 'valentines', 'pickup_lines.json'), 'r', encoding="utf8") as f: +with open(Path("bot/resources/valentines/pickup_lines.json"), "r", encoding="utf8") as f: pickup_lines = load(f) class PickupLine(commands.Cog): """A cog that gives random cheesy pickup lines.""" - def __init__(self, bot): + def __init__(self, bot: commands.Bot): self.bot = bot @commands.command() - async def pickupline(self, ctx): + async def pickupline(self, ctx: commands.Context) -> None: """ Gives you a random pickup line. Note that most of them are very cheesy. """ - random_line = random.choice(pickup_lines['lines']) embed = discord.Embed( title=':cheese: Your pickup line :cheese:', @@ -40,8 +39,7 @@ class PickupLine(commands.Cog): await ctx.send(embed=embed) -def setup(bot): +def setup(bot: commands.Bot) -> None: """Pickup lines Cog load.""" - bot.add_cog(PickupLine(bot)) log.info('PickupLine cog loaded') diff --git a/bot/seasons/valentines/savethedate.py b/bot/seasons/valentines/savethedate.py index 281625a4..e0bc3904 100644 --- a/bot/seasons/valentines/savethedate.py +++ b/bot/seasons/valentines/savethedate.py @@ -12,20 +12,19 @@ log = logging.getLogger(__name__) HEART_EMOJIS = [":heart:", ":gift_heart:", ":revolving_hearts:", ":sparkling_heart:", ":two_hearts:"] -with open(Path('bot', 'resources', 'valentines', 'date_ideas.json'), 'r', encoding="utf8") as f: +with open(Path("bot/resources/valentines/date_ideas.json"), "r", encoding="utf8") as f: VALENTINES_DATES = load(f) class SaveTheDate(commands.Cog): """A cog that gives random suggestion for a Valentine's date.""" - def __init__(self, bot): + def __init__(self, bot: commands.Bot): self.bot = bot @commands.command() - async def savethedate(self, ctx): + async def savethedate(self, ctx: commands.Context) -> None: """Gives you ideas for what to do on a date with your valentine.""" - random_date = random.choice(VALENTINES_DATES['ideas']) emoji_1 = random.choice(HEART_EMOJIS) emoji_2 = random.choice(HEART_EMOJIS) @@ -37,8 +36,7 @@ class SaveTheDate(commands.Cog): await ctx.send(embed=embed) -def setup(bot): +def setup(bot: commands.Bot) -> None: """Save the date Cog Load.""" - bot.add_cog(SaveTheDate(bot)) log.info("SaveTheDate cog loaded") diff --git a/bot/seasons/valentines/valentine_zodiac.py b/bot/seasons/valentines/valentine_zodiac.py index 1700260e..c8d77e75 100644 --- a/bot/seasons/valentines/valentine_zodiac.py +++ b/bot/seasons/valentines/valentine_zodiac.py @@ -15,25 +15,23 @@ HEART_EMOJIS = [":heart:", ":gift_heart:", ":revolving_hearts:", ":sparkling_hea class ValentineZodiac(commands.Cog): - """A cog that returns a counter compatible zodiac sign to the given user's zodiac sign.""" + """A Cog that returns a counter compatible zodiac sign to the given user's zodiac sign.""" - def __init__(self, bot): + def __init__(self, bot: commands.Bot): self.bot = bot self.zodiacs = self.load_json() @staticmethod - def load_json(): - """Load Zodiac compatibility from static JSON resource.""" - - p = Path('bot', 'resources', 'valentines', 'zodiac_compatibility.json') + def load_json() -> dict: + """Load zodiac compatibility from static JSON resource.""" + p = Path("bot/resources/valentines/zodiac_compatibility.json") with p.open() as json_data: zodiacs = load(json_data) return zodiacs @commands.command(name="partnerzodiac") - async def counter_zodiac(self, ctx, zodiac_sign): + async def counter_zodiac(self, ctx: commands.Context, zodiac_sign: str) -> None: """Provides a counter compatible zodiac sign to the given user's zodiac sign.""" - try: compatible_zodiac = random.choice(self.zodiacs[zodiac_sign.lower()]) except KeyError: @@ -54,8 +52,7 @@ class ValentineZodiac(commands.Cog): await ctx.send(embed=embed) -def setup(bot): - """Valentine Zodiac Cog load.""" - +def setup(bot: commands.Bot) -> None: + """Valentine zodiac Cog load.""" bot.add_cog(ValentineZodiac(bot)) log.info("ValentineZodiac cog loaded") diff --git a/bot/seasons/valentines/whoisvalentine.py b/bot/seasons/valentines/whoisvalentine.py index 96d97e22..b8586dca 100644 --- a/bot/seasons/valentines/whoisvalentine.py +++ b/bot/seasons/valentines/whoisvalentine.py @@ -10,20 +10,19 @@ from bot.constants import Colours log = logging.getLogger(__name__) -with open(Path("bot", "resources", "valentines", "valentine_facts.json"), "r") as file: +with open(Path("bot/resources/valentines/valentine_facts.json"), "r") as file: FACTS = json.load(file) class ValentineFacts(commands.Cog): """A Cog for displaying facts about Saint Valentine.""" - def __init__(self, bot): + def __init__(self, bot: commands.Bot): self.bot = bot @commands.command(aliases=('whoisvalentine', 'saint_valentine')) - async def who_is_valentine(self, ctx): + async def who_is_valentine(self, ctx: commands.Context) -> None: """Displays info about Saint Valentine.""" - embed = discord.Embed( title="Who is Saint Valentine?", description=FACTS['whois'], @@ -37,9 +36,8 @@ class ValentineFacts(commands.Cog): await ctx.channel.send(embed=embed) @commands.command() - async def valentine_fact(self, ctx): + async def valentine_fact(self, ctx: commands.Context) -> None: """Shows a random fact about Valentine's Day.""" - embed = discord.Embed( title=choice(FACTS['titles']), description=choice(FACTS['text']), @@ -49,8 +47,7 @@ class ValentineFacts(commands.Cog): await ctx.channel.send(embed=embed) -def setup(bot): +def setup(bot: commands.Bot) -> None: """Who is Valentine Cog load.""" - bot.add_cog(ValentineFacts(bot)) log.info("ValentineFacts cog loaded") diff --git a/bot/seasons/wildcard/__init__.py b/bot/seasons/wildcard/__init__.py new file mode 100644 index 00000000..354e979d --- /dev/null +++ b/bot/seasons/wildcard/__init__.py @@ -0,0 +1,31 @@ +from bot.seasons import SeasonBase + + +class Wildcard(SeasonBase): + """ + For the month of August, the season is a Wildcard. + + This docstring will not be used for announcements. + Instead, we'll do the announcement manually, since + it will change every year. + + This class needs slight changes every year, + such as the bot_name, bot_icon and icon. + + IMPORTANT: DO NOT ADD ANY FEATURES TO THIS FOLDER. + ALL WILDCARD FEATURES SHOULD BE ADDED + TO THE EVERGREEN FOLDER! + """ + + name = "wildcard" + bot_name = "RetroBot" + + # Duration of season + start_date = "01/08" + end_date = "01/09" + + # Season logo + bot_icon = "/logos/logo_seasonal/retro_gaming/logo_8bit_indexed_504.png" + icon = ( + "/logos/logo_seasonal/retro_gaming_animated/logo_spin_plain/logo_spin_plain_504.gif", + ) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index ef18a1b9..0aa50af6 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -1,4 +1,6 @@ import asyncio +import re +import string from typing import List import discord @@ -9,21 +11,15 @@ from bot.pagination import LinePaginator async def disambiguate( ctx: Context, entries: List[str], *, timeout: float = 30, - per_page: int = 20, empty: bool = False, embed: discord.Embed = None -): + entries_per_page: int = 20, empty: bool = False, embed: discord.Embed = None +) -> str: """ Has the user choose between multiple entries in case one could not be chosen automatically. + Disambiguation will be canceled after `timeout` seconds. + This will raise a BadArgument if entries is empty, if the disambiguation event times out, or if the user makes an invalid choice. - - :param ctx: Context object from discord.py - :param entries: List of items for user to choose from - :param timeout: Number of seconds to wait before canceling disambiguation - :param per_page: Entries per embed page - :param empty: Whether the paginator should have an extra line between items - :param embed: The embed that the paginator will use. - :return: Users choice for correct entry. """ if len(entries) == 0: raise BadArgument('No matches found.') @@ -33,7 +29,7 @@ async def disambiguate( choices = (f'{index}: {entry}' for index, entry in enumerate(entries, start=1)) - def check(message): + def check(message: discord.Message) -> bool: return (message.content.isdigit() and message.author == ctx.author and message.channel == ctx.channel) @@ -43,7 +39,7 @@ async def disambiguate( embed = discord.Embed() coro1 = ctx.bot.wait_for('message', check=check, timeout=timeout) - coro2 = LinePaginator.paginate(choices, ctx, embed=embed, max_lines=per_page, + coro2 = LinePaginator.paginate(choices, ctx, embed=embed, max_lines=entries_per_page, empty=empty, max_size=6000, timeout=9000) # wait_for timeout will go to except instead of the wait_for thing as I expected @@ -77,3 +73,57 @@ async def disambiguate( return entries[index - 1] except IndexError: raise BadArgument('Invalid choice.') + + +def replace_many( + sentence: str, replacements: dict, *, ignore_case: bool = False, match_case: bool = False +) -> str: + """ + Replaces multiple substrings in a string given a mapping of strings. + + By default replaces long strings before short strings, and lowercase before uppercase. + Example: + var = replace_many("This is a sentence", {"is": "was", "This": "That"}) + assert var == "That was a sentence" + + If `ignore_case` is given, does a case insensitive match. + Example: + var = replace_many("THIS is a sentence", {"IS": "was", "tHiS": "That"}, ignore_case=True) + assert var == "That was a sentence" + + If `match_case` is given, matches the case of the replacement with the replaced word. + Example: + var = replace_many( + "This IS a sentence", {"is": "was", "this": "that"}, ignore_case=True, match_case=True + ) + assert var == "That WAS a sentence" + """ + if ignore_case: + replacements = dict( + (word.lower(), replacement) for word, replacement in replacements.items() + ) + + words_to_replace = sorted(replacements, key=lambda s: (-len(s), s)) + + # Join and compile words to replace into a regex + pattern = "|".join(re.escape(word) for word in words_to_replace) + regex = re.compile(pattern, re.I if ignore_case else 0) + + def _repl(match: re.Match) -> str: + """Returns replacement depending on `ignore_case` and `match_case`.""" + word = match.group(0) + replacement = replacements[word.lower() if ignore_case else word] + + if not match_case: + return replacement + + # Clean punctuation from word so string methods work + cleaned_word = word.translate(str.maketrans('', '', string.punctuation)) + if cleaned_word.isupper(): + return replacement.upper() + elif cleaned_word[0].isupper(): + return replacement.capitalize() + else: + return replacement.lower() + + return regex.sub(_repl, sentence) diff --git a/bot/utils/halloween/spookifications.py b/bot/utils/halloween/spookifications.py index 390cfa49..11f69850 100644 --- a/bot/utils/halloween/spookifications.py +++ b/bot/utils/halloween/spookifications.py @@ -7,21 +7,19 @@ from PIL import ImageOps log = logging.getLogger() -def inversion(im): +def inversion(im: Image) -> Image: """ Inverts the image. Returns an inverted image when supplied with an Image object. """ - im = im.convert('RGB') inv = ImageOps.invert(im) return inv -def pentagram(im): +def pentagram(im: Image) -> Image: """Adds pentagram to the image.""" - im = im.convert('RGB') wt, ht = im.size penta = Image.open('bot/resources/halloween/bloody-pentagram.png') @@ -30,14 +28,13 @@ def pentagram(im): return im -def bat(im): +def bat(im: Image) -> Image: """ Adds a bat silhoutte to the image. The bat silhoutte is of a size at least one-fifths that of the original image and may be rotated up to 90 degrees anti-clockwise. """ - im = im.convert('RGB') wt, ht = im.size bat = Image.open('bot/resources/halloween/bat-clipart.png') @@ -53,9 +50,8 @@ def bat(im): return im -def get_random_effect(im): +def get_random_effect(im: Image) -> Image: """Randomly selects and applies an effect.""" - effects = [inversion, pentagram, bat] effect = choice(effects) log.info("Spookyavatar's chosen effect: " + effect.__name__) diff --git a/bot/utils/persist.py b/bot/utils/persist.py new file mode 100644 index 00000000..a60a1219 --- /dev/null +++ b/bot/utils/persist.py @@ -0,0 +1,66 @@ +import sqlite3 +from pathlib import Path +from shutil import copyfile + +from bot.seasons.season import get_seasons + +DIRECTORY = Path("data") # directory that has a persistent volume mapped to it + + +def make_persistent(file_path: Path) -> Path: + """ + Copy datafile at the provided file_path to the persistent data directory. + + A persistent data file is needed by some features in order to not lose data + after bot rebuilds. + + This function will ensure that a clean data file with default schema, + structure or data is copied over to the persistent volume before returning + the path to this new persistent version of the file. + + If the persistent file already exists, it won't be overwritten with the + clean default file, just returning the Path instead to the existing file. + + Note: Avoid using the same file name as other features in the same seasons + as otherwise only one datafile can be persistent and will be returned for + both cases. + + Example Usage: + >>> import json + >>> template_datafile = Path("bot", "resources", "evergreen", "myfile.json") + >>> path_to_persistent_file = make_persistent(template_datafile) + >>> print(path_to_persistent_file) + data/evergreen/myfile.json + >>> with path_to_persistent_file.open("w+") as f: + >>> data = json.load(f) + """ + # ensure the persistent data directory exists + DIRECTORY.mkdir(exist_ok=True) + + if not file_path.is_file(): + raise OSError(f"File not found at {file_path}.") + + # detect season in datafile path for assigning to subdirectory + season = next((s for s in get_seasons() if s in file_path.parts), None) + + if season: + # make sure subdirectory exists first + subdirectory = Path(DIRECTORY, season) + subdirectory.mkdir(exist_ok=True) + + persistent_path = Path(subdirectory, file_path.name) + + else: + persistent_path = Path(DIRECTORY, file_path.name) + + # copy base/template datafile to persistent directory + if not persistent_path.exists(): + copyfile(file_path, persistent_path) + + return persistent_path + + +def sqlite(db_path: Path) -> sqlite3.Connection: + """Copy sqlite file to the persistent data directory and return an open connection.""" + persistent_path = make_persistent(db_path) + return sqlite3.connect(persistent_path) |