aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Dockerfile2
-rw-r--r--Pipfile2
-rw-r--r--Pipfile.lock52
-rw-r--r--azure-pipelines.yml4
-rw-r--r--bot/__main__.py1
-rw-r--r--bot/bot.py2
-rw-r--r--bot/constants.py4
-rw-r--r--bot/help.py549
8 files changed, 564 insertions, 52 deletions
diff --git a/Dockerfile b/Dockerfile
index 940f90d5..b8a8a1e4 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM python:3.7-slim
+FROM python:3.8-slim
# Set pip to have cleaner logs and no saved cache
ENV PIP_NO_CACHE_DIR=false \
diff --git a/Pipfile b/Pipfile
index 0c4b2353..1c260a69 100644
--- a/Pipfile
+++ b/Pipfile
@@ -25,7 +25,7 @@ pep8-naming = "~=0.9"
pre-commit = "~=2.1"
[requires]
-python_version = "3.7"
+python_version = "3.8"
[scripts]
start = "python -m bot"
diff --git a/Pipfile.lock b/Pipfile.lock
index 659a046c..426514e5 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,11 +1,11 @@
{
"_meta": {
"hash": {
- "sha256": "4cd9801f890f8087b7a46b239264e6b09d4c29c35223118add96bed0af22b475"
+ "sha256": "b117417a1dbcc28039ecac9579d54efa6437c621f0132eb06a8aa4f990d30a00"
},
"pipfile-spec": 6,
"requires": {
- "python_version": "3.7"
+ "python_version": "3.8"
},
"sources": [
{
@@ -431,14 +431,6 @@
],
"version": "==1.4.11"
},
- "importlib-metadata": {
- "hashes": [
- "sha256:06f5b3a99029c7134207dd882428a66992a9de2bef7c2b699b5641f9886c3302",
- "sha256:b97607a1a18a5100839aec1dc26a1ea17ee0d93b20b0f008d80a5a050afb200b"
- ],
- "markers": "python_version < '3.8'",
- "version": "==1.5.0"
- },
"mccabe": {
"hashes": [
"sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
@@ -526,46 +518,12 @@
],
"version": "==0.10.0"
},
- "typed-ast": {
- "hashes": [
- "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355",
- "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919",
- "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa",
- "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652",
- "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75",
- "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01",
- "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d",
- "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1",
- "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907",
- "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c",
- "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3",
- "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b",
- "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614",
- "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb",
- "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b",
- "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41",
- "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6",
- "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34",
- "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe",
- "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4",
- "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"
- ],
- "markers": "python_version < '3.8'",
- "version": "==1.4.1"
- },
"virtualenv": {
"hashes": [
- "sha256:0c04c7e8e0314470b4c2b43740ff68be1c62bb3fdef8309341ff1daea60d49d1",
- "sha256:1f0369d068d9761b5c1ed7b44dad1ec124727eb10bc7f4aaefbba0cdca3bd924"
+ "sha256:5eba85dfa176fde0425b9b3042ed83f05a1b6309a616b8a3e2a9a94f4bfa27b7",
+ "sha256:99f131be2f90ff2a8fd711261a27845b6c50fc008bef815e710c7fa844eb1467"
],
- "version": "==20.0.8"
- },
- "zipp": {
- "hashes": [
- "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b",
- "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"
- ],
- "version": "==3.1.0"
+ "version": "==20.0.9"
}
}
}
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
index d56261a6..687fdc1e 100644
--- a/azure-pipelines.yml
+++ b/azure-pipelines.yml
@@ -5,7 +5,7 @@ jobs:
displayName: 'Lint & Test'
pool:
- vmImage: 'Ubuntu 16.04'
+ vmImage: 'Ubuntu 18.04'
variables:
PIP_CACHE_DIR: ".cache/pip"
@@ -22,7 +22,7 @@ jobs:
displayName: 'Set Python version'
name: PythonVersion
inputs:
- versionSpec: '3.7.x'
+ versionSpec: '3.8.x'
addToPath: true
- script: pip3 install pipenv
diff --git a/bot/__main__.py b/bot/__main__.py
index 9dc0b173..a169257f 100644
--- a/bot/__main__.py
+++ b/bot/__main__.py
@@ -7,5 +7,6 @@ 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.help")
bot.load_extension("bot.seasons")
bot.run(Client.token)
diff --git a/bot/bot.py b/bot/bot.py
index 2a723021..8b389b6a 100644
--- a/bot/bot.py
+++ b/bot/bot.py
@@ -28,7 +28,7 @@ class SeasonalBot(commands.Bot):
# Unload all cogs
extensions = list(self.extensions.keys())
for extension in extensions:
- if extension != "bot.seasons": # We shouldn't unload the manager.
+ if extension not in ["bot.seasons", "bot.help"]: # We shouldn't unload the manager and help.
self.unload_extension(extension)
# Load in the list of cogs that was passed in here
diff --git a/bot/constants.py b/bot/constants.py
index b9dbeb39..a868370b 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -104,6 +104,10 @@ class Emojis:
merge = "<:PRMerged:629695470570176522>"
+class Icons:
+ questionmark = "https://cdn.discordapp.com/emojis/512367613339369475.png"
+
+
class Lovefest:
role_id = int(environ.get("LOVEFEST_ROLE_ID", 542431903886606399))
diff --git a/bot/help.py b/bot/help.py
new file mode 100644
index 00000000..7209b71e
--- /dev/null
+++ b/bot/help.py
@@ -0,0 +1,549 @@
+# Help command from Python bot. All commands that will be added to there in futures should be added to here too.
+import asyncio
+import itertools
+from collections import namedtuple
+from contextlib import suppress
+from typing import Union
+
+from discord import Colour, Embed, HTTPException, Message, Reaction, User
+from discord.ext import commands
+from discord.ext.commands import CheckFailure, Cog as DiscordCog, Command, Context
+from fuzzywuzzy import fuzz, process
+
+from bot import constants
+from bot.bot import SeasonalBot
+from bot.constants import Emojis
+from bot.pagination import (
+ FIRST_EMOJI, LAST_EMOJI,
+ LEFT_EMOJI, LinePaginator, RIGHT_EMOJI,
+)
+
+DELETE_EMOJI = Emojis.trashcan
+
+REACTIONS = {
+ FIRST_EMOJI: 'first',
+ LEFT_EMOJI: 'back',
+ RIGHT_EMOJI: 'next',
+ LAST_EMOJI: 'end',
+ DELETE_EMOJI: 'stop',
+}
+
+Cog = namedtuple('Cog', ['name', 'description', 'commands'])
+
+
+class HelpQueryNotFound(ValueError):
+ """
+ Raised when a HelpSession Query doesn't match a command or cog.
+
+ Contains the custom attribute of ``possible_matches``.
+ Instances of this object contain a dictionary of any command(s) that were close to matching the
+ query, where keys are the possible matched command names and values are the likeness match scores.
+ """
+
+ def __init__(self, arg: str, possible_matches: dict = None):
+ super().__init__(arg)
+ self.possible_matches = possible_matches
+
+
+class HelpSession:
+ """
+ An interactive session for bot and command help output.
+
+ Expected attributes include:
+ * title: str
+ The title of the help message.
+ * query: Union[discord.ext.commands.Bot, discord.ext.commands.Command]
+ * description: str
+ The description of the query.
+ * pages: list[str]
+ A list of the help content split into manageable pages.
+ * message: `discord.Message`
+ The message object that's showing the help contents.
+ * destination: `discord.abc.Messageable`
+ Where the help message is to be sent to.
+ Cogs can be grouped into custom categories. All cogs with the same category will be displayed
+ under a single category name in the help output. Custom categories are defined inside the cogs
+ as a class attribute named `category`. A description can also be specified with the attribute
+ `category_description`. If a description is not found in at least one cog, the default will be
+ the regular description (class docstring) of the first cog found in the category.
+ """
+
+ def __init__(
+ self,
+ ctx: Context,
+ *command,
+ cleanup: bool = False,
+ only_can_run: bool = True,
+ show_hidden: bool = False,
+ max_lines: int = 15
+ ):
+ """Creates an instance of the HelpSession class."""
+ self._ctx = ctx
+ self._bot = ctx.bot
+ self.title = "Command Help"
+
+ # set the query details for the session
+ if command:
+ query_str = ' '.join(command)
+ self.query = self._get_query(query_str)
+ self.description = self.query.description or self.query.help
+ else:
+ self.query = ctx.bot
+ self.description = self.query.description
+ self.author = ctx.author
+ self.destination = ctx.channel
+
+ # set the config for the session
+ self._cleanup = cleanup
+ self._only_can_run = only_can_run
+ self._show_hidden = show_hidden
+ self._max_lines = max_lines
+
+ # init session states
+ self._pages = None
+ self._current_page = 0
+ self.message = None
+ self._timeout_task = None
+ self.reset_timeout()
+
+ def _get_query(self, query: str) -> Union[Command, Cog]:
+ """Attempts to match the provided query with a valid command or cog."""
+ command = self._bot.get_command(query)
+ if command:
+ return command
+
+ # Find all cog categories that match.
+ cog_matches = []
+ description = None
+ for cog in self._bot.cogs.values():
+ if hasattr(cog, "category") and cog.category == query:
+ cog_matches.append(cog)
+ if hasattr(cog, "category_description"):
+ description = cog.category_description
+
+ # Try to search by cog name if no categories match.
+ if not cog_matches:
+ cog = self._bot.cogs.get(query)
+
+ # Don't consider it a match if the cog has a category.
+ if cog and not hasattr(cog, "category"):
+ cog_matches = [cog]
+
+ if cog_matches:
+ cog = cog_matches[0]
+ cmds = (cog.get_commands() for cog in cog_matches) # Commands of all cogs
+
+ return Cog(
+ name=cog.category if hasattr(cog, "category") else cog.qualified_name,
+ description=description or cog.description,
+ commands=tuple(itertools.chain.from_iterable(cmds)) # Flatten the list
+ )
+
+ self._handle_not_found(query)
+
+ def _handle_not_found(self, query: str) -> None:
+ """
+ Handles when a query does not match a valid command or cog.
+
+ Will pass on possible close matches along with the `HelpQueryNotFound` exception.
+ """
+ # Combine command and cog names
+ choices = list(self._bot.all_commands) + list(self._bot.cogs)
+
+ result = process.extractBests(query, choices, scorer=fuzz.ratio, score_cutoff=90)
+
+ raise HelpQueryNotFound(f'Query "{query}" not found.', dict(result))
+
+ async def timeout(self, seconds: int = 30) -> None:
+ """Waits for a set number of seconds, then stops the help session."""
+ await asyncio.sleep(seconds)
+ await self.stop()
+
+ def reset_timeout(self) -> None:
+ """Cancels the original timeout task and sets it again from the start."""
+ # cancel original if it exists
+ if self._timeout_task:
+ if not self._timeout_task.cancelled():
+ self._timeout_task.cancel()
+
+ # recreate the timeout task
+ self._timeout_task = self._bot.loop.create_task(self.timeout())
+
+ async def on_reaction_add(self, reaction: Reaction, user: User) -> None:
+ """Event handler for when reactions are added on the help message."""
+ # ensure it was the relevant session message
+ if reaction.message.id != self.message.id:
+ return
+
+ # ensure it was the session author who reacted
+ if user.id != self.author.id:
+ return
+
+ emoji = str(reaction.emoji)
+
+ # check if valid action
+ if emoji not in REACTIONS:
+ return
+
+ self.reset_timeout()
+
+ # Run relevant action method
+ action = getattr(self, f'do_{REACTIONS[emoji]}', None)
+ if action:
+ await action()
+
+ # remove the added reaction to prep for re-use
+ with suppress(HTTPException):
+ await self.message.remove_reaction(reaction, user)
+
+ async def on_message_delete(self, message: Message) -> None:
+ """Closes the help session when the help message is deleted."""
+ if message.id == self.message.id:
+ await self.stop()
+
+ async def prepare(self) -> None:
+ """Sets up the help session pages, events, message and reactions."""
+ await self.build_pages()
+
+ self._bot.add_listener(self.on_reaction_add)
+ self._bot.add_listener(self.on_message_delete)
+
+ await self.update_page()
+ self.add_reactions()
+
+ def add_reactions(self) -> None:
+ """Adds the relevant reactions to the help message based on if pagination is required."""
+ # if paginating
+ if len(self._pages) > 1:
+ for reaction in REACTIONS:
+ self._bot.loop.create_task(self.message.add_reaction(reaction))
+
+ # if single-page
+ else:
+ self._bot.loop.create_task(self.message.add_reaction(DELETE_EMOJI))
+
+ def _category_key(self, cmd: Command) -> str:
+ """
+ Returns a cog name of a given command for use as a key for `sorted` and `groupby`.
+
+ A zero width space is used as a prefix for results with no cogs to force them last in ordering.
+ """
+ if cmd.cog:
+ try:
+ if cmd.cog.category:
+ return f'**{cmd.cog.category}**'
+ except AttributeError:
+ pass
+
+ return f'**{cmd.cog_name}**'
+ else:
+ return "**\u200bNo Category:**"
+
+ def _get_command_params(self, cmd: Command) -> str:
+ """
+ Returns the command usage signature.
+
+ This is a custom implementation of `command.signature` in order to format the command
+ signature without aliases.
+ """
+ results = []
+ for name, param in cmd.clean_params.items():
+
+ # if argument has a default value
+ if param.default is not param.empty:
+
+ if isinstance(param.default, str):
+ show_default = param.default
+ else:
+ show_default = param.default is not None
+
+ # if default is not an empty string or None
+ if show_default:
+ results.append(f'[{name}={param.default}]')
+ else:
+ results.append(f'[{name}]')
+
+ # if variable length argument
+ elif param.kind == param.VAR_POSITIONAL:
+ results.append(f'[{name}...]')
+
+ # if required
+ else:
+ results.append(f'<{name}>')
+
+ return f"{cmd.name} {' '.join(results)}"
+
+ async def build_pages(self) -> None:
+ """Builds the list of content pages to be paginated through in the help message, as a list of str."""
+ # Use LinePaginator to restrict embed line height
+ paginator = LinePaginator(prefix='', suffix='', max_lines=self._max_lines)
+
+ prefix = constants.Client.prefix
+
+ # show signature if query is a command
+ if isinstance(self.query, commands.Command):
+ signature = self._get_command_params(self.query)
+ parent = self.query.full_parent_name + ' ' if self.query.parent else ''
+ paginator.add_line(f'**```{prefix}{parent}{signature}```**')
+
+ aliases = ', '.join(f'`{a}`' for a in self.query.aliases)
+ if aliases:
+ paginator.add_line(f'**Can also use:** {aliases}\n')
+
+ if not await self.query.can_run(self._ctx):
+ paginator.add_line('***You cannot run this command.***\n')
+
+ if isinstance(self.query, Cog):
+ paginator.add_line(f'**{self.query.name}**')
+
+ if self.description:
+ paginator.add_line(f'*{self.description}*')
+
+ # list all children commands of the queried object
+ if isinstance(self.query, (commands.GroupMixin, Cog)):
+
+ # remove hidden commands if session is not wanting hiddens
+ if not self._show_hidden:
+ filtered = [c for c in self.query.commands if not c.hidden]
+ else:
+ filtered = self.query.commands
+
+ # if after filter there are no commands, finish up
+ if not filtered:
+ self._pages = paginator.pages
+ return
+
+ if isinstance(self.query, Cog):
+ grouped = (('**Commands:**', self.query.commands),)
+
+ elif isinstance(self.query, commands.Command):
+ grouped = (('**Subcommands:**', self.query.commands),)
+
+ # don't show prefix for subcommands
+ prefix = ''
+
+ # otherwise sort and organise all commands into categories
+ else:
+ cat_sort = sorted(filtered, key=self._category_key)
+ grouped = itertools.groupby(cat_sort, key=self._category_key)
+
+ for category, cmds in grouped:
+ cmds = sorted(cmds, key=lambda c: c.name)
+
+ if len(cmds) == 0:
+ continue
+
+ cat_cmds = []
+
+ for command in cmds:
+
+ # skip if hidden and hide if session is set to
+ if command.hidden and not self._show_hidden:
+ continue
+
+ # see if the user can run the command
+ strikeout = ''
+
+ # Patch to make the !help command work outside of #bot-commands again
+ # This probably needs a proper rewrite, but this will make it work in
+ # the mean time.
+ try:
+ can_run = await command.can_run(self._ctx)
+ except CheckFailure:
+ can_run = False
+
+ if not can_run:
+ # skip if we don't show commands they can't run
+ if self._only_can_run:
+ continue
+ strikeout = '~~'
+
+ signature = self._get_command_params(command)
+ info = f"{strikeout}**`{prefix}{signature}`**{strikeout}"
+
+ # handle if the command has no docstring
+ if command.short_doc:
+ cat_cmds.append(f'{info}\n*{command.short_doc}*')
+ else:
+ cat_cmds.append(f'{info}\n*No details provided.*')
+
+ # state var for if the category should be added next
+ print_cat = 1
+ new_page = True
+
+ for details in cat_cmds:
+
+ # keep details together, paginating early if it won't fit
+ lines_adding = len(details.split('\n')) + print_cat
+ if paginator._linecount + lines_adding > self._max_lines:
+ paginator._linecount = 0
+ new_page = True
+ paginator.close_page()
+
+ # new page so print category title again
+ print_cat = 1
+
+ if print_cat:
+ if new_page:
+ paginator.add_line('')
+ paginator.add_line(category)
+ print_cat = 0
+
+ paginator.add_line(details)
+
+ self._pages = paginator.pages
+
+ def embed_page(self, page_number: int = 0) -> Embed:
+ """Returns an Embed with the requested page formatted within."""
+ embed = Embed()
+
+ if isinstance(self.query, (commands.Command, Cog)) and page_number > 0:
+ title = f'Command Help | "{self.query.name}"'
+ else:
+ title = self.title
+
+ embed.set_author(name=title, icon_url=constants.Icons.questionmark)
+ embed.description = self._pages[page_number]
+
+ page_count = len(self._pages)
+ if page_count > 1:
+ embed.set_footer(text=f'Page {self._current_page+1} / {page_count}')
+
+ return embed
+
+ async def update_page(self, page_number: int = 0) -> None:
+ """Sends the intial message, or changes the existing one to the given page number."""
+ self._current_page = page_number
+ embed_page = self.embed_page(page_number)
+
+ if not self.message:
+ self.message = await self.destination.send(embed=embed_page)
+ else:
+ await self.message.edit(embed=embed_page)
+
+ @classmethod
+ async def start(cls, ctx: Context, *command, **options) -> "HelpSession":
+ """
+ Create and begin a help session based on the given command context.
+
+ Available options kwargs:
+ * cleanup: Optional[bool]
+ Set to `True` to have the message deleted on session end. Defaults to `False`.
+ * only_can_run: Optional[bool]
+ Set to `True` to hide commands the user can't run. Defaults to `False`.
+ * show_hidden: Optional[bool]
+ Set to `True` to include hidden commands. Defaults to `False`.
+ * max_lines: Optional[int]
+ Sets the max number of lines the paginator will add to a single page. Defaults to 20.
+ """
+ session = cls(ctx, *command, **options)
+ await session.prepare()
+
+ return session
+
+ async def stop(self) -> None:
+ """Stops the help session, removes event listeners and attempts to delete the help message."""
+ self._bot.remove_listener(self.on_reaction_add)
+ self._bot.remove_listener(self.on_message_delete)
+
+ # ignore if permission issue, or the message doesn't exist
+ with suppress(HTTPException, AttributeError):
+ if self._cleanup:
+ await self.message.delete()
+ else:
+ await self.message.clear_reactions()
+
+ @property
+ def is_first_page(self) -> bool:
+ """Check if session is currently showing the first page."""
+ return self._current_page == 0
+
+ @property
+ def is_last_page(self) -> bool:
+ """Check if the session is currently showing the last page."""
+ return self._current_page == (len(self._pages)-1)
+
+ async def do_first(self) -> None:
+ """Event that is called when the user requests the first page."""
+ if not self.is_first_page:
+ await self.update_page(0)
+
+ async def do_back(self) -> None:
+ """Event that is called when the user requests the previous page."""
+ if not self.is_first_page:
+ await self.update_page(self._current_page-1)
+
+ async def do_next(self) -> None:
+ """Event that is called when the user requests the next page."""
+ if not self.is_last_page:
+ await self.update_page(self._current_page+1)
+
+ async def do_end(self) -> None:
+ """Event that is called when the user requests the last page."""
+ if not self.is_last_page:
+ await self.update_page(len(self._pages)-1)
+
+ async def do_stop(self) -> None:
+ """Event that is called when the user requests to stop the help session."""
+ await self.message.delete()
+
+
+class Help(DiscordCog):
+ """Custom Embed Pagination Help feature."""
+
+ @commands.command('help')
+ async def new_help(self, ctx: Context, *commands) -> None:
+ """Shows Command Help."""
+ try:
+ await HelpSession.start(ctx, *commands)
+ except HelpQueryNotFound as error:
+ embed = Embed()
+ embed.colour = Colour.red()
+ embed.title = str(error)
+
+ if error.possible_matches:
+ matches = '\n'.join(error.possible_matches.keys())
+ embed.description = f'**Did you mean:**\n`{matches}`'
+
+ await ctx.send(embed=embed)
+
+
+def unload(bot: SeasonalBot) -> None:
+ """
+ Reinstates the original help command.
+
+ This is run if the cog raises an exception on load, or if the extension is unloaded.
+ """
+ bot.remove_command('help')
+ bot.add_command(bot._old_help)
+
+
+def setup(bot: SeasonalBot) -> None:
+ """
+ The setup for the help extension.
+
+ This is called automatically on `bot.load_extension` being run.
+ Stores the original help command instance on the `bot._old_help` attribute for later
+ reinstatement, before removing it from the command registry so the new help command can be
+ loaded successfully.
+ If an exception is raised during the loading of the cog, `unload` will be called in order to
+ reinstate the original help command.
+ """
+ bot._old_help = bot.get_command('help')
+ bot.remove_command('help')
+
+ try:
+ bot.add_cog(Help())
+ except Exception:
+ unload(bot)
+ raise
+
+
+def teardown(bot: SeasonalBot) -> None:
+ """
+ The teardown for the help extension.
+
+ This is called automatically on `bot.unload_extension` being run.
+ Calls `unload` in order to reinstate the original help command.
+ """
+ unload(bot)