diff options
| author | 2019-12-20 08:50:31 -0800 | |
|---|---|---|
| committer | 2019-12-20 08:50:31 -0800 | |
| commit | e3529c7bbc7590fed089b8197b4e98630ee10253 (patch) | |
| tree | 7333c48ed164e5819a5e610cb77aeafc7a70bc5a /bot | |
| parent | Update bot/seasons/evergreen/trivia_quiz.py (diff) | |
| parent | Merge pull request #332 from python-discord/errorhandler-refine (diff) | |
Merge branch 'master' into quiz_fix
Diffstat (limited to 'bot')
| -rw-r--r-- | bot/constants.py | 15 | ||||
| -rw-r--r-- | bot/resources/advent_of_code/about.json | 2 | ||||
| -rw-r--r-- | bot/resources/halloween/monster.json | 41 | ||||
| -rw-r--r-- | bot/seasons/christmas/__init__.py | 9 | ||||
| -rw-r--r-- | bot/seasons/christmas/adventofcode.py | 67 | ||||
| -rw-r--r-- | bot/seasons/easter/easter_riddle.py | 2 | ||||
| -rw-r--r-- | bot/seasons/easter/egg_decorating.py | 2 | ||||
| -rw-r--r-- | bot/seasons/evergreen/__init__.py | 1 | ||||
| -rw-r--r-- | bot/seasons/evergreen/bookmark.py | 55 | ||||
| -rw-r--r-- | bot/seasons/evergreen/error_handler.py | 225 | ||||
| -rw-r--r-- | bot/seasons/halloween/hacktoberstats.py | 2 | ||||
| -rw-r--r-- | bot/seasons/halloween/monsterbio.py | 56 | ||||
| -rw-r--r-- | bot/seasons/season.py | 9 | ||||
| -rw-r--r-- | bot/utils/__init__.py | 23 | 
14 files changed, 368 insertions, 141 deletions
| diff --git a/bot/constants.py b/bot/constants.py index aa5c3db3..eca4f67b 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -1,8 +1,10 @@  import logging  from os import environ  from typing import NamedTuple +from datetime import datetime  __all__ = ( +    "bookmark_icon_url",      "AdventOfCode", "Channels", "Client", "Colours", "Emojis", "Hacktoberfest", "Roles", "Tokens",      "WHITELISTED_CHANNELS", "STAFF_ROLES", "MODERATION_ROLES",      "POSITIVE_REPLIES", "NEGATIVE_REPLIES", "ERROR_REPLIES", @@ -10,19 +12,24 @@ __all__ = (  log = logging.getLogger(__name__) +bookmark_icon_url = ( +    "https://images-ext-2.discordapp.net/external/zl4oDwcmxUILY7sD9ZWE2fU5R7n6QcxEmPYSE5eddbg/" +    "%3Fv%3D1/https/cdn.discordapp.com/emojis/654080405988966419.png?width=20&height=20" +) +  class AdventOfCode:      leaderboard_cache_age_threshold_seconds = 3600 -    leaderboard_id = 363275 +    leaderboard_id = 631135      leaderboard_join_code = str(environ.get("AOC_JOIN_CODE", None))      leaderboard_max_displayed_members = 10 -    year = 2018 +    year = int(environ.get("AOC_YEAR", datetime.utcnow().year))      role_id = int(environ.get("AOC_ROLE_ID", 518565788744024082))  class Channels(NamedTuple):      admins = 365960823622991872 -    advent_of_code = 517745814039166986 +    advent_of_code = int(environ.get("AOC_CHANNEL_ID", 517745814039166986))      announcements = int(environ.get("CHANNEL_ANNOUNCEMENTS", 354619224620138496))      big_brother_logs = 468507907357409333      bot = 267659945086812160 @@ -73,12 +80,14 @@ class Colours:      soft_green = 0x68c290      soft_red = 0xcd6d6d      yellow = 0xf9f586 +    purple = 0xb734eb  class Emojis:      star = "\u2B50"      christmas_tree = "\U0001F384"      check = "\u2611" +    envelope = "\U0001F4E8"      terning1 = "<:terning1:431249668983488527>"      terning2 = "<:terning2:462339216987127808>" diff --git a/bot/resources/advent_of_code/about.json b/bot/resources/advent_of_code/about.json index 4abf9145..b1d16a93 100644 --- a/bot/resources/advent_of_code/about.json +++ b/bot/resources/advent_of_code/about.json @@ -16,7 +16,7 @@      },      {          "name": "How does scoring work?", -        "value": "Getting a star first is worth 100 points, second is 99, and so on down to 1 point at 100th place.\n\nCheck out AoC's [global leaderboard](https://adventofcode.com/2018/leaderboard) to see who's leading this year's event!", +        "value": "Getting a star first is worth 100 points, second is 99, and so on down to 1 point at 100th place.\n\nCheck out AoC's [global leaderboard](https://adventofcode.com/leaderboard) to see who's leading this year's event!",          "inline": false      },      { diff --git a/bot/resources/halloween/monster.json b/bot/resources/halloween/monster.json new file mode 100644 index 00000000..5958dc9c --- /dev/null +++ b/bot/resources/halloween/monster.json @@ -0,0 +1,41 @@ +{ +  "monster_type": [ +    ["El", "Go", "Ma", "Nya", "Wo", "Hom", "Shar", "Gronn", "Grom", "Blar"], +    ["gaf", "mot", "phi", "zyme", "qur", "tile", "pim"], +    ["yam", "ja", "rok", "pym", "el"], +    ["ya", "tor", "tir", "tyre", "pam"] +  ], +  "scientist_first_name": ["Ellis", "Elliot", "Rick", "Laurent", "Morgan", "Sophia", "Oak"], +  "scientist_last_name": ["E. M.", "E. T.", "Smith", "Schimm", "Schiftner", "Smile", "Tomson", "Thompson", "Huffson", "Argor", "Lephtain", "S. M.", "A. R.", "P. G."], +  "verb": [ +    "discovered", "created", "found" +  ], +  "adjective": [ +    "ferocious", "spectacular", "incredible", "terrifying" +  ], +  "physical_adjective": [ +    "springy", "rubbery", "bouncy", "tough", "notched", "chipped" +  ], +  "color": [ +    "blue", "green", "teal", "black", "pure white", "obsidian black", "purple", "bright red", "bright yellow" +  ], +  "attribute": [ +    "horns", "teeth", "shell", "fur", "bones", "exoskeleton", "spikes" +  ], +  "ability": [ +    "breathe fire", "devour dreams", "lift thousand-pound weights", "devour metal", "chew up diamonds", "create diamonds", "create gemstones", "breathe icy cold air", "spit poison", "live forever" +  ], +  "ingredients": [ +    "witch's eye", "frog legs", "slime", "true love's kiss", "a lock of golden hair", "the skin of a snake", "a never-melting chunk of ice" +  ], +  "time": [ +    "dusk", "dawn", "mid-day", "midnight on a full moon", "midnight on Halloween night", "the time of a solar eclipse", "the time of a lunar eclipse." +  ], +  "year": [ +    "1996", "1594", "1330", "1700" +  ], +  "biography_text": [ +    {"scientist_first_name": 1, "scientist_last_name": 1, "verb": 1, "adjective": 1, "attribute": 1, "ability": 1, "color": 1, "year": 1, "time": 1, "physical_adjective": 1, "text": "Your name is {monster_name}, a member of the {adjective} species {monster_species}. The first {monster_species} was {verb} by {scientist_first_name} {scientist_last_name} in {year} at {time}. The species {monster_species} is known for its {physical_adjective} {color} {attribute}. It is said to even be able to {ability}!"}, +    {"scientist_first_name": 1, "scientist_last_name": 1, "adjective": 1, "attribute": 1, "physical_adjective": 1, "ingredients": 2, "time": 1, "ability": 1, "verb": 1, "color": 1, "year": 1, "text": "The {monster_species} is an {adjective} species, and you, {monster_name}, are no exception. {monster_species} is famed for its {physical_adjective} {attribute}. Whispers say that when brewed with {ingredients[0]} and {ingredients[1]} at {time}, a foul, {color} brew will be produced, granting it's drinker the ability to {ability}! This species was {verb} by {scientist_first_name} {scientist_last_name} in {year}."} +  ] +} diff --git a/bot/seasons/christmas/__init__.py b/bot/seasons/christmas/__init__.py index ae93800e..4287efb7 100644 --- a/bot/seasons/christmas/__init__.py +++ b/bot/seasons/christmas/__init__.py @@ -1,3 +1,5 @@ +import datetime +  from bot.constants import Colours  from bot.seasons import SeasonBase @@ -22,5 +24,10 @@ class Christmas(SeasonBase):      colour = Colours.dark_green      icon = ( -        "/logos/logo_seasonal/christmas/festive.png", +        "/logos/logo_seasonal/christmas/2019/festive_512.gif",      ) + +    @classmethod +    def end(cls) -> datetime.datetime: +        """Overload the `SeasonBase` method to account for the event ending in the next year.""" +        return datetime.datetime.strptime(f"{cls.end_date}/{cls.current_year() + 1}", cls.date_format) diff --git a/bot/seasons/christmas/adventofcode.py b/bot/seasons/christmas/adventofcode.py index 007e4783..f2ec83df 100644 --- a/bot/seasons/christmas/adventofcode.py +++ b/bot/seasons/christmas/adventofcode.py @@ -15,6 +15,7 @@ from pytz import timezone  from bot.constants import AdventOfCode as AocConfig, Channels, Colours, Emojis, Tokens, WHITELISTED_CHANNELS  from bot.decorators import override_in_channel +from bot.utils import unlocked_role  log = logging.getLogger(__name__) @@ -85,17 +86,42 @@ async def day_countdown(bot: commands.Bot) -> None:      while is_in_advent():          tomorrow, time_left = time_left_to_aoc_midnight() -        await asyncio.sleep(time_left.seconds) +        # Correct `time_left.seconds` for the sleep we have after unlocking the role (-5) and adding +        # a second (+1) as the bot is consistently ~0.5 seconds early in announcing the puzzles. +        await asyncio.sleep(time_left.seconds - 4) -        channel = bot.get_channel(Channels.seasonalbot_chat) +        channel = bot.get_channel(Channels.advent_of_code)          if not channel:              log.error("Could not find the AoC channel to send notification in")              break -        await channel.send(f"<@&{AocConfig.role_id}> Good morning! Day {tomorrow.day} is ready to be attempted. " -                           f"View it online now at https://adventofcode.com/{AocConfig.year}/day/{tomorrow.day}" -                           f" (this link could take a few minutes to start working). Good luck!") +        aoc_role = channel.guild.get_role(AocConfig.role_id) +        if not aoc_role: +            log.error("Could not find the AoC role to announce the daily puzzle") +            break + +        async with unlocked_role(aoc_role, delay=5): +            puzzle_url = f"https://adventofcode.com/{AocConfig.year}/day/{tomorrow.day}" + +            # Check if the puzzle is already available to prevent our members from spamming +            # the puzzle page before it's available by making a small HEAD request. +            for retry in range(1, 5): +                log.debug(f"Checking if the puzzle is already available (attempt {retry}/4)") +                async with bot.http_session.head(puzzle_url, raise_for_status=False) as resp: +                    if resp.status == 200: +                        log.debug("Puzzle is available; let's send an announcement message.") +                        break +                log.debug(f"The puzzle is not yet available (status={resp.status})") +                await asyncio.sleep(10) +            else: +                log.error("The puzzle does does not appear to be available at this time, canceling announcement") +                break + +            await channel.send( +                f"{aoc_role.mention} Good morning! Day {tomorrow.day} is ready to be attempted. " +                f"View it online now at {puzzle_url}. Good luck!" +            )          # Wait a couple minutes so that if our sleep didn't sleep enough          # time we don't end up announcing twice. @@ -122,10 +148,10 @@ class AdventOfCode(commands.Cog):          self.status_task = None          countdown_coro = day_countdown(self.bot) -        self.countdown_task = asyncio.ensure_future(self.bot.loop.create_task(countdown_coro)) +        self.countdown_task = self.bot.loop.create_task(countdown_coro)          status_coro = countdown_status(self.bot) -        self.status_task = asyncio.ensure_future(self.bot.loop.create_task(status_coro)) +        self.status_task = self.bot.loop.create_task(status_coro)      @commands.group(name="adventofcode", aliases=("aoc",), invoke_without_command=True)      @override_in_channel(AOC_WHITELIST) @@ -170,10 +196,21 @@ class AdventOfCode(commands.Cog):          """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) -            delta = december_first - datetime_now + +            # Calculate the delta to this & next year's December 1st to see which one is closest and not in the past +            this_year = datetime(datetime_now.year, 12, 1, tzinfo=EST) +            next_year = datetime(datetime_now.year + 1, 12, 1, tzinfo=EST) +            deltas = (dec_first - datetime_now for dec_first in (this_year, next_year)) +            delta = min(delta for delta in deltas if delta >= timedelta())  # timedelta() gives 0 duration delta + +            # Add a finer timedelta if there's less than a day left +            if delta.days == 0: +                delta_str = f"approximately {delta.seconds // 3600} hours" +            else: +                delta_str = f"{delta.days} days" +              await ctx.send(f"The Advent of Code event is not currently running. " -                           f"The next event will start in {delta.days} days.") +                           f"The next event will start in {delta_str}.")              return          tomorrow, time_left = time_left_to_aoc_midnight() @@ -188,7 +225,7 @@ class AdventOfCode(commands.Cog):          """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") +    @adventofcode_group.command(name="join", aliases=("j",), brief="Learn how to join the leaderboard (via DM)")      @override_in_channel(AOC_WHITELIST)      async def join_leaderboard(self, ctx: commands.Context) -> None:          """DM the user the information for joining the PyDis AoC private leaderboard.""" @@ -204,6 +241,8 @@ class AdventOfCode(commands.Cog):          except discord.errors.Forbidden:              log.debug(f"{author.name} ({author.id}) has disabled DMs from server members")              await ctx.send(f":x: {author.mention}, please (temporarily) enable DMs to receive the join code") +        else: +            await ctx.message.add_reaction(Emojis.envelope)      @adventofcode_group.command(          name="leaderboard", @@ -407,6 +446,12 @@ class AdventOfCode(commands.Cog):          else:              self.cached_private_leaderboard = await AocPrivateLeaderboard.from_url() +    def cog_unload(self) -> None: +        """Cancel season-related tasks on cog unload.""" +        log.debug("Unloading the cog and canceling the background task.") +        self.countdown_task.cancel() +        self.status_task.cancel() +  class AocMember:      """Object representing the Advent of Code user.""" diff --git a/bot/seasons/easter/easter_riddle.py b/bot/seasons/easter/easter_riddle.py index 4b98b204..f5b1aac7 100644 --- a/bot/seasons/easter/easter_riddle.py +++ b/bot/seasons/easter/easter_riddle.py @@ -83,7 +83,7 @@ class EasterRiddle(commands.Cog):          self.current_channel = None      @commands.Cog.listener() -    async def on_message(self, message: discord.Messaged) -> None: +    async def on_message(self, message: discord.Message) -> None:          """If a non-bot user enters a correct answer, their username gets added to self.winners."""          if self.current_channel != message.channel:              return diff --git a/bot/seasons/easter/egg_decorating.py b/bot/seasons/easter/egg_decorating.py index 51f52264..23df95f1 100644 --- a/bot/seasons/easter/egg_decorating.py +++ b/bot/seasons/easter/egg_decorating.py @@ -46,7 +46,7 @@ class EggDecorating(commands.Cog):      @commands.command(aliases=["decorateegg"])      async def eggdecorate(          self, ctx: commands.Context, *colours: Union[discord.Colour, str] -    ) -> Union[Image, discord.Message]: +    ) -> Union[Image.Image, discord.Message]:          """          Picks a random egg design and decorates it using the given colours. diff --git a/bot/seasons/evergreen/__init__.py b/bot/seasons/evergreen/__init__.py index c2746552..b3d0dc63 100644 --- a/bot/seasons/evergreen/__init__.py +++ b/bot/seasons/evergreen/__init__.py @@ -13,4 +13,5 @@ class Evergreen(SeasonBase):          "/logos/logo_animated/jumper/jumper_512.gif",          "/logos/logo_animated/apple/apple_512.gif",          "/logos/logo_animated/blinky/blinky_512.gif", +        "/logos/logo_animated/runner/runner_512.gif",      ) diff --git a/bot/seasons/evergreen/bookmark.py b/bot/seasons/evergreen/bookmark.py new file mode 100644 index 00000000..9962186f --- /dev/null +++ b/bot/seasons/evergreen/bookmark.py @@ -0,0 +1,55 @@ +import logging
 +import random
 +
 +import discord
 +from discord.ext import commands
 +
 +from bot.constants import Colours, ERROR_REPLIES, Emojis, bookmark_icon_url
 +
 +log = logging.getLogger(__name__)
 +
 +
 +class Bookmark(commands.Cog):
 +    """Creates personal bookmarks by relaying a message link to the user's DMs."""
 +
 +    def __init__(self, bot: commands.Bot):
 +        self.bot = bot
 +
 +    @commands.command(name="bookmark", aliases=("bm", "pin"))
 +    async def bookmark(
 +        self,
 +        ctx: commands.Context,
 +        target_message: discord.Message,
 +        *,
 +        title: str = "Bookmark"
 +    ) -> None:
 +        """Send the author a link to `target_message` via DMs."""
 +        log.info(f"{ctx.author} bookmarked {target_message.jump_url} with title '{title}'")
 +        embed = discord.Embed(
 +            title=title,
 +            colour=Colours.soft_green,
 +            description=(
 +                f"{target_message.content}\n\n"
 +                f"[Visit original message]({target_message.jump_url})"
 +            )
 +        )
 +        embed.set_author(name=target_message.author, icon_url=target_message.author.avatar_url)
 +        embed.set_thumbnail(url=bookmark_icon_url)
 +
 +        try:
 +            await ctx.author.send(embed=embed)
 +        except discord.Forbidden:
 +            error_embed = discord.Embed(
 +                title=random.choice(ERROR_REPLIES),
 +                description=f"{ctx.author.mention}, please enable your DMs to receive the bookmark",
 +                colour=Colours.soft_red
 +            )
 +            await ctx.send(embed=error_embed)
 +        else:
 +            await ctx.message.add_reaction(Emojis.envelope)
 +
 +
 +def setup(bot: commands.Bot) -> None:
 +    """Load the Bookmark cog."""
 +    bot.add_cog(Bookmark(bot))
 +    log.info("Bookmark cog loaded")
 diff --git a/bot/seasons/evergreen/error_handler.py b/bot/seasons/evergreen/error_handler.py index 120462ee..0d8bb0bb 100644 --- a/bot/seasons/evergreen/error_handler.py +++ b/bot/seasons/evergreen/error_handler.py @@ -1,119 +1,106 @@ -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: commands.Bot):
 -        self.bot = bot
 -
 -    @staticmethod
 -    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)
 -            bucket._tokens = min(bucket.rate, bucket._tokens + 1)
 -            logging.debug(
 -                "Cooldown counter reverted as the command was not used correctly."
 -            )
 -
 -    @commands.Cog.listener()
 -    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."
 -            )
 -
 -        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."
 -            )
 -
 -        if isinstance(error, commands.UserInputError):
 -            logging.debug(
 -                f"{ctx.author} called the command '{ctx.command}' but entered invalid input!"
 -            )
 -
 -            self.revert_cooldown_counter(ctx.command, ctx.message)
 -
 -            return await ctx.send(
 -                ":no_entry: The command you specified failed to run. "
 -                "This is because the arguments you provided were invalid."
 -            )
 -
 -        if isinstance(error, commands.CommandOnCooldown):
 -            logging.debug(
 -                f"{ctx.author} called the command '{ctx.command}' but they were on cooldown!"
 -            )
 -            remaining_minutes, remaining_seconds = divmod(error.retry_after, 60)
 -
 -            return await ctx.send(
 -                "This command is on cooldown, please retry in "
 -                f"{int(remaining_minutes)} minutes {math.ceil(remaining_seconds)} seconds."
 -            )
 -
 -        if isinstance(error, commands.DisabledCommand):
 -            logging.debug(
 -                f"{ctx.author} called the command '{ctx.command}' but the command was disabled!"
 -            )
 -            return await ctx.send(":no_entry: This command has been disabled.")
 -
 -        if isinstance(error, commands.NoPrivateMessage):
 -            logging.debug(
 -                f"{ctx.author} called the command '{ctx.command}' "
 -                "in a private message however the command was guild only!"
 -            )
 -            return await ctx.author.send(":no_entry: This command can only be used in the server.")
 -
 -        if isinstance(error, commands.BadArgument):
 -            self.revert_cooldown_counter(ctx.command, ctx.message)
 -
 -            logging.debug(
 -                f"{ctx.author} called the command '{ctx.command}' but entered a bad argument!"
 -            )
 -            return await ctx.send("The argument you provided was invalid.")
 -
 -        if isinstance(error, commands.CheckFailure):
 -            logging.debug(f"{ctx.author} called the command '{ctx.command}' but the checks failed!")
 -            return await ctx.send(":no_entry: You are not authorized to use this command.")
 -
 -        print(f"Ignoring exception in command {ctx.command}:", file=sys.stderr)
 -
 -        logging.warning(
 -            f"{ctx.author} called the command '{ctx.command}' "
 -            "however the command failed to run with the error:"
 -            f"-------------\n{error}"
 -        )
 -
 -        traceback.print_exception(type(error), error, error.__traceback__, file=sys.stderr)
 -
 -
 -def setup(bot: commands.Bot) -> None:
 -    """Error handler Cog load."""
 -    bot.add_cog(CommandErrorHandler(bot))
 -    log.info("CommandErrorHandler cog loaded")
 +import logging +import math +import random +from typing import Iterable, Union + +from discord import Embed, Message +from discord.ext import commands + +from bot.constants import Colours, ERROR_REPLIES, 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: commands.Bot): +        self.bot = bot + +    @staticmethod +    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) +            bucket._tokens = min(bucket.rate, bucket._tokens + 1) +            logging.debug("Cooldown counter reverted as the command was not used correctly.") + +    @staticmethod +    def error_embed(message: str, title: Union[Iterable, str] = ERROR_REPLIES) -> Embed: +        """Build a basic embed with red colour and either a random error title or a title provided.""" +        embed = Embed(colour=Colours.soft_red) +        if isinstance(title, str): +            embed.title = title +        else: +            embed.title = random.choice(title) +        embed.description = message +        return embed + +    @commands.Cog.listener() +    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'): +            logging.debug("A command error occured but the command had it's own error handler.") +            return + +        error = getattr(error, 'original', error) +        logging.debug( +            f"Error Encountered: {type(error).__name__} - {str(error)}, " +            f"Command: {ctx.command}, " +            f"Author: {ctx.author}, " +            f"Channel: {ctx.channel}" +        ) + +        if isinstance(error, commands.CommandNotFound): +            return + +        if isinstance(error, InChannelCheckFailure): +            await ctx.send(embed=self.error_embed(str(error), NEGATIVE_REPLIES), delete_after=7.5) +            return + +        if isinstance(error, commands.UserInputError): +            self.revert_cooldown_counter(ctx.command, ctx.message) +            embed = self.error_embed( +                f"Your input was invalid: {error}\n\nUsage:\n```{ctx.prefix}{ctx.command} {ctx.command.signature}```" +            ) +            await ctx.send(embed=embed) +            return + +        if isinstance(error, commands.CommandOnCooldown): +            mins, secs = divmod(math.ceil(error.retry_after), 60) +            embed = self.error_embed( +                f"This command is on cooldown:\nPlease retry in {mins} minutes {secs} seconds.", +                NEGATIVE_REPLIES +            ) +            await ctx.send(embed=embed, delete_after=7.5) +            return + +        if isinstance(error, commands.DisabledCommand): +            await ctx.send(embed=self.error_embed("This command has been disabled.", NEGATIVE_REPLIES)) +            return + +        if isinstance(error, commands.NoPrivateMessage): +            await ctx.send(embed=self.error_embed("This command can only be used in the server.", NEGATIVE_REPLIES)) +            return + +        if isinstance(error, commands.BadArgument): +            self.revert_cooldown_counter(ctx.command, ctx.message) +            embed = self.error_embed( +                "The argument you provided was invalid: " +                f"{error}\n\nUsage:\n```{ctx.prefix}{ctx.command} {ctx.command.signature}```" +            ) +            await ctx.send(embed=embed) +            return + +        if isinstance(error, commands.CheckFailure): +            await ctx.send(embed=self.error_embed("You are not authorized to use this command.", NEGATIVE_REPLIES)) +            return + +        log.exception(f"Unhandled command error: {str(error)}", exc_info=error) + + +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/halloween/hacktoberstats.py b/bot/seasons/halloween/hacktoberstats.py index ab8d865c..b7b4122d 100644 --- a/bot/seasons/halloween/hacktoberstats.py +++ b/bot/seasons/halloween/hacktoberstats.py @@ -227,6 +227,7 @@ class HacktoberStats(commands.Cog):          not_label = "invalid"          action_type = "pr"          is_query = f"public+author:{github_username}" +        not_query = "draft"          date_range = f"{CURRENT_YEAR}-10-01T00:00:00%2B14:00..{CURRENT_YEAR}-10-31T23:59:59-11:00"          per_page = "300"          query_url = ( @@ -234,6 +235,7 @@ class HacktoberStats(commands.Cog):              f"-label:{not_label}"              f"+type:{action_type}"              f"+is:{is_query}" +            f"+-is:{not_query}"              f"+created:{date_range}"              f"&per_page={per_page}"          ) diff --git a/bot/seasons/halloween/monsterbio.py b/bot/seasons/halloween/monsterbio.py new file mode 100644 index 00000000..bfa8a026 --- /dev/null +++ b/bot/seasons/halloween/monsterbio.py @@ -0,0 +1,56 @@ +import json +import logging +import random +from pathlib import Path + +import discord +from discord.ext import commands + +from bot.constants import Colours + +log = logging.getLogger(__name__) + +with open(Path("bot/resources/halloween/monster.json"), "r", encoding="utf8") as f: +    TEXT_OPTIONS = json.load(f)  # Data for a mad-lib style generation of text + + +class MonsterBio(commands.Cog): +    """A cog that generates a spooky monster biography.""" + +    def __init__(self, bot: commands.Bot): +        self.bot = bot + +    def generate_name(self, seeded_random: random.Random) -> str: +        """Generates a name (for either monster species or monster name).""" +        n_candidate_strings = seeded_random.randint(2, len(TEXT_OPTIONS["monster_type"])) +        return "".join(seeded_random.choice(TEXT_OPTIONS["monster_type"][i]) for i in range(n_candidate_strings)) + +    @commands.command(brief="Sends your monster bio!") +    async def monsterbio(self, ctx: commands.Context) -> None: +        """Sends a description of a monster.""" +        seeded_random = random.Random(ctx.message.author.id)  # Seed a local Random instance rather than the system one + +        name = self.generate_name(seeded_random) +        species = self.generate_name(seeded_random) +        biography_text = seeded_random.choice(TEXT_OPTIONS["biography_text"]) +        words = {"monster_name": name, "monster_species": species} +        for key, value in biography_text.items(): +            if key == "text": +                continue + +            options = seeded_random.sample(TEXT_OPTIONS[key], value) +            words[key] = ' '.join(options) + +        embed = discord.Embed( +            title=f"{name}'s Biography", +            color=seeded_random.choice([Colours.orange, Colours.purple]), +            description=biography_text["text"].format_map(words), +        ) + +        await ctx.send(embed=embed) + + +def setup(bot: commands.Bot) -> None: +    """Monster bio Cog load.""" +    bot.add_cog(MonsterBio(bot)) +    log.info("MonsterBio cog loaded.") diff --git a/bot/seasons/season.py b/bot/seasons/season.py index 3546fda6..e7b7a69c 100644 --- a/bot/seasons/season.py +++ b/bot/seasons/season.py @@ -79,6 +79,7 @@ class SeasonBase:      start_date: Optional[str] = None      end_date: Optional[str] = None +    should_announce: bool = False      colour: Optional[int] = None      icon: Tuple[str, ...] = ("/logos/logo_full/logo_full.png",) @@ -268,11 +269,11 @@ class SeasonBase:          """          Announces a change in season in the announcement channel. -        It will skip the announcement if the current active season is the "evergreen" default season. +        Auto-announcement is configured by the `should_announce` `SeasonBase` attribute          """ -        # Don't actually announce if reverting to normal season -        if self.name in ("evergreen", "wildcard", "halloween"): -            log.debug(f"Season Changed: {self.name}") +        # Short circuit if the season had disabled automatic announcements +        if not self.should_announce: +            log.debug(f"Season changed without announcement: {self.name}")              return          guild = bot.get_guild(Client.guild) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 0aa50af6..25fd4b96 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -1,4 +1,5 @@  import asyncio +import contextlib  import re  import string  from typing import List @@ -127,3 +128,25 @@ def replace_many(              return replacement.lower()      return regex.sub(_repl, sentence) + + +async def unlocked_role(role: discord.Role, delay: int = 5) -> None: +    """ +    Create a context in which `role` is unlocked, relocking it automatically after use. + +    A configurable `delay` is added before yielding the context and directly after exiting the +    context to allow the role settings change to properly propagate at Discord's end. This +    prevents things like role mentions from failing because of synchronization issues. + +    Usage: +    >>> async with unlocked_role(role, delay=5): +    ...     await ctx.send(f"Hey {role.mention}, free pings for everyone!") +    """ +    await role.edit(mentionable=True) +    await asyncio.sleep(delay) +    try: +        yield +    finally: +        await asyncio.sleep(delay) +        await role.edit(mentionable=False) | 
