aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--bot/cogs/bot.py2
-rw-r--r--bot/cogs/clean.py2
-rw-r--r--bot/cogs/defcon.py2
-rw-r--r--bot/cogs/error_handler.py33
-rw-r--r--bot/cogs/eval.py2
-rw-r--r--bot/cogs/extensions.py8
-rw-r--r--bot/cogs/help.py713
-rw-r--r--bot/cogs/help_channels.py2
-rw-r--r--bot/cogs/moderation/management.py2
-rw-r--r--bot/cogs/off_topic_names.py2
-rw-r--r--bot/cogs/reddit.py10
-rw-r--r--bot/cogs/reminders.py2
-rw-r--r--bot/cogs/site.py2
-rw-r--r--bot/cogs/snekbox.py2
-rw-r--r--bot/cogs/utils.py2
-rw-r--r--bot/cogs/watchchannels/bigbrother.py2
-rw-r--r--bot/cogs/watchchannels/talentpool.py4
-rw-r--r--bot/cogs/wolfram.py8
-rw-r--r--bot/decorators.py6
-rw-r--r--bot/pagination.py2
-rw-r--r--bot/resources/tags/mutability.md37
-rw-r--r--config-default.yml2
-rw-r--r--tests/bot/cogs/test_snekbox.py5
23 files changed, 349 insertions, 503 deletions
diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py
index a6929b431..f6aea51c5 100644
--- a/bot/cogs/bot.py
+++ b/bot/cogs/bot.py
@@ -41,7 +41,7 @@ class BotCog(Cog, name="Bot"):
@with_role(Roles.verified)
async def botinfo_group(self, ctx: Context) -> None:
"""Bot informational commands."""
- await ctx.invoke(self.bot.get_command("help"), "bot")
+ await ctx.send_help(ctx.command)
@botinfo_group.command(name='about', aliases=('info',), hidden=True)
@with_role(Roles.verified)
diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py
index 5cdf0b048..b5d9132cb 100644
--- a/bot/cogs/clean.py
+++ b/bot/cogs/clean.py
@@ -180,7 +180,7 @@ class Clean(Cog):
@with_role(*MODERATION_ROLES)
async def clean_group(self, ctx: Context) -> None:
"""Commands for cleaning messages in channels."""
- await ctx.invoke(self.bot.get_command("help"), "clean")
+ await ctx.send_help(ctx.command)
@clean_group.command(name="user", aliases=["users"])
@with_role(*MODERATION_ROLES)
diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py
index 56fca002a..25b0a6ad5 100644
--- a/bot/cogs/defcon.py
+++ b/bot/cogs/defcon.py
@@ -122,7 +122,7 @@ class Defcon(Cog):
@with_role(Roles.admins, Roles.owners)
async def defcon_group(self, ctx: Context) -> None:
"""Check the DEFCON status or run a subcommand."""
- await ctx.invoke(self.bot.get_command("help"), "defcon")
+ await ctx.send_help(ctx.command)
async def _defcon_action(self, ctx: Context, days: int, action: Action) -> None:
"""Providing a structured way to do an defcon action."""
diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py
index b2f4c59f6..23d1eed82 100644
--- a/bot/cogs/error_handler.py
+++ b/bot/cogs/error_handler.py
@@ -2,7 +2,7 @@ import contextlib
import logging
import typing as t
-from discord.ext.commands import Cog, Command, Context, errors
+from discord.ext.commands import Cog, Context, errors
from sentry_sdk import push_scope
from bot.api import ResponseCodeError
@@ -79,19 +79,13 @@ class ErrorHandler(Cog):
f"{e.__class__.__name__}: {e}"
)
- async def get_help_command(self, command: t.Optional[Command]) -> t.Tuple:
- """Return the help command invocation args to display help for `command`."""
- parent = None
- if command is not None:
- parent = command.parent
-
- # Retrieve the help command for the invoked command.
- if parent and command:
- return self.bot.get_command("help"), parent.name, command.name
- elif command:
- return self.bot.get_command("help"), command.name
- else:
- return self.bot.get_command("help")
+ @staticmethod
+ def get_help_command(ctx: Context) -> t.Coroutine:
+ """Return a prepared `help` command invocation coroutine."""
+ if ctx.command:
+ return ctx.send_help(ctx.command)
+
+ return ctx.send_help()
async def try_silence(self, ctx: Context) -> bool:
"""
@@ -165,20 +159,19 @@ class ErrorHandler(Cog):
* ArgumentParsingError: send an error message
* Other: send an error message and the help command
"""
- # TODO: use ctx.send_help() once PR #519 is merged.
- help_command = await self.get_help_command(ctx.command)
+ prepared_help_command = self.get_help_command(ctx)
if isinstance(e, errors.MissingRequiredArgument):
await ctx.send(f"Missing required argument `{e.param.name}`.")
- await ctx.invoke(*help_command)
+ await prepared_help_command
self.bot.stats.incr("errors.missing_required_argument")
elif isinstance(e, errors.TooManyArguments):
await ctx.send(f"Too many arguments provided.")
- await ctx.invoke(*help_command)
+ await prepared_help_command
self.bot.stats.incr("errors.too_many_arguments")
elif isinstance(e, errors.BadArgument):
await ctx.send(f"Bad argument: {e}\n")
- await ctx.invoke(*help_command)
+ await prepared_help_command
self.bot.stats.incr("errors.bad_argument")
elif isinstance(e, errors.BadUnionArgument):
await ctx.send(f"Bad argument: {e}\n```{e.errors[-1]}```")
@@ -188,7 +181,7 @@ class ErrorHandler(Cog):
self.bot.stats.incr("errors.argument_parsing_error")
else:
await ctx.send("Something about your input seems off. Check the arguments:")
- await ctx.invoke(*help_command)
+ await prepared_help_command
self.bot.stats.incr("errors.other_user_input_error")
@staticmethod
diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py
index 52136fc8d..eb8bfb1cf 100644
--- a/bot/cogs/eval.py
+++ b/bot/cogs/eval.py
@@ -178,7 +178,7 @@ async def func(): # (None,) -> Any
async def internal_group(self, ctx: Context) -> None:
"""Internal commands. Top secret!"""
if not ctx.invoked_subcommand:
- await ctx.invoke(self.bot.get_command("help"), "internal")
+ await ctx.send_help(ctx.command)
@internal_group.command(name='eval', aliases=('e',))
@with_role(Roles.admins, Roles.owners)
diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py
index fb6cd9aa3..365f198ff 100644
--- a/bot/cogs/extensions.py
+++ b/bot/cogs/extensions.py
@@ -65,7 +65,7 @@ class Extensions(commands.Cog):
@group(name="extensions", aliases=("ext", "exts", "c", "cogs"), invoke_without_command=True)
async def extensions_group(self, ctx: Context) -> None:
"""Load, unload, reload, and list loaded extensions."""
- await ctx.invoke(self.bot.get_command("help"), "extensions")
+ await ctx.send_help(ctx.command)
@extensions_group.command(name="load", aliases=("l",))
async def load_command(self, ctx: Context, *extensions: Extension) -> None:
@@ -75,7 +75,7 @@ class Extensions(commands.Cog):
If '\*' or '\*\*' is given as the name, all unloaded extensions will be loaded.
""" # noqa: W605
if not extensions:
- await ctx.invoke(self.bot.get_command("help"), "extensions load")
+ await ctx.send_help(ctx.command)
return
if "*" in extensions or "**" in extensions:
@@ -92,7 +92,7 @@ class Extensions(commands.Cog):
If '\*' or '\*\*' is given as the name, all loaded extensions will be unloaded.
""" # noqa: W605
if not extensions:
- await ctx.invoke(self.bot.get_command("help"), "extensions unload")
+ await ctx.send_help(ctx.command)
return
blacklisted = "\n".join(UNLOAD_BLACKLIST & set(extensions))
@@ -118,7 +118,7 @@ class Extensions(commands.Cog):
If '\*\*' is given as the name, all extensions, including unloaded ones, will be reloaded.
""" # noqa: W605
if not extensions:
- await ctx.invoke(self.bot.get_command("help"), "extensions reload")
+ await ctx.send_help(ctx.command)
return
if "**" in extensions:
diff --git a/bot/cogs/help.py b/bot/cogs/help.py
index 744722220..542f19139 100644
--- a/bot/cogs/help.py
+++ b/bot/cogs/help.py
@@ -1,34 +1,48 @@
-import asyncio
import itertools
+import logging
+from asyncio import TimeoutError
from collections import namedtuple
from contextlib import suppress
-from typing import Union
+from typing import List, 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 discord import Colour, Embed, Member, Message, NotFound, Reaction, User
+from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand
from fuzzywuzzy import fuzz, process
from bot import constants
-from bot.bot import Bot
from bot.constants import Channels, Emojis, STAFF_ROLES
from bot.decorators import redirect_output
-from bot.pagination import (
- FIRST_EMOJI, LAST_EMOJI,
- LEFT_EMOJI, LinePaginator, RIGHT_EMOJI,
-)
+from bot.pagination import LinePaginator
+log = logging.getLogger(__name__)
+
+COMMANDS_PER_PAGE = 8
DELETE_EMOJI = Emojis.trashcan
+PREFIX = constants.Bot.prefix
+
+Category = namedtuple("Category", ["name", "description", "cogs"])
+
+
+async def help_cleanup(bot: Bot, author: Member, message: Message) -> None:
+ """
+ Runs the cleanup for the help command.
+
+ Adds the :trashcan: reaction that, when clicked, will delete the help message.
+ After a 300 second timeout, the reaction will be removed.
+ """
+ def check(reaction: Reaction, user: User) -> bool:
+ """Checks the reaction is :trashcan:, the author is original author and messages are the same."""
+ return str(reaction) == DELETE_EMOJI and user.id == author.id and reaction.message.id == message.id
-REACTIONS = {
- FIRST_EMOJI: 'first',
- LEFT_EMOJI: 'back',
- RIGHT_EMOJI: 'next',
- LAST_EMOJI: 'end',
- DELETE_EMOJI: 'stop',
-}
+ await message.add_reaction(DELETE_EMOJI)
-Cog = namedtuple('Cog', ['name', 'description', 'commands'])
+ try:
+ await bot.wait_for("reaction_add", check=check, timeout=300)
+ await message.delete()
+ except TimeoutError:
+ await message.remove_reaction(DELETE_EMOJI, bot.user)
+ except NotFound:
+ pass
class HelpQueryNotFound(ValueError):
@@ -46,22 +60,9 @@ class HelpQueryNotFound(ValueError):
self.possible_matches = possible_matches
-class HelpSession:
+class CustomHelpCommand(HelpCommand):
"""
- 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.
+ An interactive instance for the bot help command.
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
@@ -70,499 +71,299 @@ class HelpSession:
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]:
+ def __init__(self):
+ super().__init__(command_attrs={"help": "Shows help for bot commands"})
+
+ @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_ROLES)
+ async def command_callback(self, ctx: Context, *, command: str = None) -> None:
"""Attempts to match the provided query with a valid command or cog."""
- command = self._bot.get_command(query)
- if command:
- return command
+ # the only reason we need to tamper with this is because d.py does not support "categories",
+ # so we need to deal with them ourselves.
+
+ bot = ctx.bot
+
+ if command is None:
+ # quick and easy, send bot help if command is none
+ mapping = self.get_bot_mapping()
+ await self.send_bot_help(mapping)
+ return
- # 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:
+ for cog in bot.cogs.values():
+ if hasattr(cog, "category") and cog.category == command:
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:
+ category = Category(name=command, description=description, cogs=cog_matches)
+ await self.send_category_help(category)
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."""
- # create paginated content
- await self.build_pages()
-
- # setup listeners
- self._bot.add_listener(self.on_reaction_add)
- self._bot.add_listener(self.on_message_delete)
-
- # Send the help message
- 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))
+ # it's either a cog, group, command or subcommand; let the parent class deal with it
+ await super().command_callback(ctx, command=command)
- # if single-page
- else:
- self._bot.loop.create_task(self.message.add_reaction(DELETE_EMOJI))
-
- def _category_key(self, cmd: Command) -> str:
+ async def get_all_help_choices(self) -> set:
"""
- Returns a cog name of a given command for use as a key for `sorted` and `groupby`.
+ Get all the possible options for getting help in the bot.
- 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:**"
+ This will only display commands the author has permission to run.
- def _get_command_params(self, cmd: Command) -> str:
- """
- Returns the command usage signature.
+ These include:
+ - Category names
+ - Cog names
+ - Group command names (and aliases)
+ - Command names (and aliases)
+ - Subcommand names (with parent group and aliases for subcommand, but not including aliases for group)
- This is a custom implementation of `command.signature` in order to format the command
- signature without aliases.
+ Options and choices are case sensitive.
"""
- 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
+ # first get all commands including subcommands and full command name aliases
+ choices = set()
+ for command in await self.filter_commands(self.context.bot.walk_commands()):
+ # the the command or group name
+ choices.add(str(command))
+
+ if isinstance(command, Command):
+ # all aliases if it's just a command
+ choices.update(command.aliases)
else:
- results.append(f'<{name}>')
+ # otherwise we need to add the parent name in
+ choices.update(f"{command.full_parent_name} {alias}" for alias in command.aliases)
- return f"{cmd.name} {' '.join(results)}"
+ # all cog names
+ choices.update(self.context.bot.cogs)
- 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)
+ # all category names
+ choices.update(cog.category for cog in self.context.bot.cogs.values() if hasattr(cog, "category"))
+ return choices
- prefix = constants.Bot.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}```**')
-
- # show command aliases
- 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')
-
- # show name if query is a cog
- if isinstance(self.query, Cog):
- paginator.add_line(f'**{self.query.name}**')
+ async def command_not_found(self, string: str) -> "HelpQueryNotFound":
+ """
+ Handles when a query does not match a valid command, group, cog or category.
- if self.description:
- paginator.add_line(f'*{self.description}*')
+ Will return an instance of the `HelpQueryNotFound` exception with the error message and possible matches.
+ """
+ choices = await self.get_all_help_choices()
+ result = process.extractBests(string, choices, scorer=fuzz.ratio, score_cutoff=60)
- # list all children commands of the queried object
- if isinstance(self.query, (commands.GroupMixin, Cog)):
+ return HelpQueryNotFound(f'Query "{string}" not found.', dict(result))
- # 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
+ async def subcommand_not_found(self, command: Command, string: str) -> "HelpQueryNotFound":
+ """
+ Redirects the error to `command_not_found`.
- # if after filter there are no commands, finish up
- if not filtered:
- self._pages = paginator.pages
- return
+ `command_not_found` deals with searching and getting best choices for both commands and subcommands.
+ """
+ return await self.command_not_found(f"{command.qualified_name} {string}")
- # set category to Commands if cog
- if isinstance(self.query, Cog):
- grouped = (('**Commands:**', self.query.commands),)
+ async def send_error_message(self, error: HelpQueryNotFound) -> None:
+ """Send the error message to the channel."""
+ embed = Embed(colour=Colour.red(), title=str(error))
- # set category to Subcommands if command
- elif isinstance(self.query, commands.Command):
- grouped = (('**Subcommands:**', self.query.commands),)
+ if getattr(error, "possible_matches", None):
+ matches = "\n".join(f"`{match}`" for match in error.possible_matches)
+ embed.description = f"**Did you mean:**\n{matches}"
- # don't show prefix for subcommands
- prefix = ''
+ await self.context.send(embed=embed)
- # 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)
+ async def command_formatting(self, command: Command) -> Embed:
+ """
+ Takes a command and turns it into an embed.
- # process each category
- for category, cmds in grouped:
- cmds = sorted(cmds, key=lambda c: c.name)
+ It will add an author, command signature + help, aliases and a note if the user can't run the command.
+ """
+ embed = Embed()
+ embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark)
- # if there are no commands, skip category
- if len(cmds) == 0:
- continue
+ parent = command.full_parent_name
- cat_cmds = []
+ name = str(command) if not parent else f"{parent} {command.name}"
+ command_details = f"**```{PREFIX}{name} {command.signature}```**\n"
- # format details for each child command
- for command in cmds:
+ # show command aliases
+ aliases = ", ".join(f"`{alias}`" if not parent else f"`{parent} {alias}`" for alias in command.aliases)
+ if aliases:
+ command_details += f"**Can also use:** {aliases}\n\n"
- # skip if hidden and hide if session is set to
- if command.hidden and not self._show_hidden:
- continue
+ # check if the user is allowed to run this command
+ if not await command.can_run(self.context):
+ command_details += "***You cannot run this command.***\n\n"
- # see if the user can run the command
- strikeout = ''
+ command_details += f"*{command.help or 'No details provided.'}*\n"
+ embed.description = command_details
- # 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
+ return embed
- if not can_run:
- # skip if we don't show commands they can't run
- if self._only_can_run:
- continue
- strikeout = '~~'
+ async def send_command_help(self, command: Command) -> None:
+ """Send help for a single command."""
+ embed = await self.command_formatting(command)
+ message = await self.context.send(embed=embed)
+ await help_cleanup(self.context.bot, self.context.author, message)
- signature = self._get_command_params(command)
- info = f"{strikeout}**`{prefix}{signature}`**{strikeout}"
+ @staticmethod
+ def get_commands_brief_details(commands_: List[Command], return_as_list: bool = False) -> Union[List[str], str]:
+ """
+ Formats the prefix, command name and signature, and short doc for an iterable of commands.
- # 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.*')
+ return_as_list is helpful for passing these command details into the paginator as a list of command details.
+ """
+ details = []
+ for command in commands_:
+ signature = f" {command.signature}" if command.signature else ""
+ details.append(
+ f"\n**`{PREFIX}{command.qualified_name}{signature}`**\n*{command.short_doc or 'No details provided'}*"
+ )
+ if return_as_list:
+ return details
+ else:
+ return "".join(details)
- # state var for if the category should be added next
- print_cat = 1
- new_page = True
+ async def send_group_help(self, group: Group) -> None:
+ """Sends help for a group command."""
+ subcommands = group.commands
- for details in cat_cmds:
+ if len(subcommands) == 0:
+ # no subcommands, just treat it like a regular command
+ await self.send_command_help(group)
+ return
- # 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()
+ # remove commands that the user can't run and are hidden, and sort by name
+ commands_ = await self.filter_commands(subcommands, sort=True)
- # new page so print category title again
- print_cat = 1
+ embed = await self.command_formatting(group)
- if print_cat:
- if new_page:
- paginator.add_line('')
- paginator.add_line(category)
- print_cat = 0
+ command_details = self.get_commands_brief_details(commands_)
+ if command_details:
+ embed.description += f"\n**Subcommands:**\n{command_details}"
- paginator.add_line(details)
+ message = await self.context.send(embed=embed)
+ await help_cleanup(self.context.bot, self.context.author, message)
- # save organised pages to session
- self._pages = paginator.pages
+ async def send_cog_help(self, cog: Cog) -> None:
+ """Send help for a cog."""
+ # sort commands by name, and remove any the user cant run or are hidden.
+ commands_ = await self.filter_commands(cog.get_commands(), sort=True)
- def embed_page(self, page_number: int = 0) -> Embed:
- """Returns an Embed with the requested page formatted within."""
embed = Embed()
+ embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark)
+ embed.description = f"**{cog.qualified_name}**\n*{cog.description}*"
- # if command or cog, add query to title for pages other than first
- 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]
+ command_details = self.get_commands_brief_details(commands_)
+ if command_details:
+ embed.description += f"\n\n**Commands:**\n{command_details}"
- # add page counter to footer if paginating
- page_count = len(self._pages)
- if page_count > 1:
- embed.set_footer(text=f'Page {self._current_page+1} / {page_count}')
+ message = await self.context.send(embed=embed)
+ await help_cleanup(self.context.bot, self.context.author, message)
- 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)
+ @staticmethod
+ def _category_key(command: Command) -> str:
+ """
+ Returns a cog name of a given command for use as a key for `sorted` and `groupby`.
- if not self.message:
- self.message = await self.destination.send(embed=embed_page)
+ A zero width space is used as a prefix for results with no cogs to force them last in ordering.
+ """
+ if command.cog:
+ with suppress(AttributeError):
+ if command.cog.category:
+ return f"**{command.cog.category}**"
+ return f"**{command.cog_name}**"
else:
- await self.message.edit(embed=embed_page)
+ return "**\u200bNo Category:**"
- @classmethod
- async def start(cls, ctx: Context, *command, **options) -> "HelpSession":
+ async def send_category_help(self, category: Category) -> None:
"""
- 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.
+ Sends help for a bot category.
+
+ This sends a brief help for all commands in all cogs registered to the category.
"""
- session = cls(ctx, *command, **options)
- await session.prepare()
+ embed = Embed()
+ embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark)
- return session
+ all_commands = []
+ for cog in category.cogs:
+ all_commands.extend(cog.get_commands())
- 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)
+ filtered_commands = await self.filter_commands(all_commands, sort=True)
- # 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()
+ command_detail_lines = self.get_commands_brief_details(filtered_commands, return_as_list=True)
+ description = f"**{category.name}**\n*{category.description}*"
- @property
- def is_first_page(self) -> bool:
- """Check if session is currently showing the first page."""
- return self._current_page == 0
+ if command_detail_lines:
+ description += "\n\n**Commands:**"
- @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)
+ await LinePaginator.paginate(
+ command_detail_lines,
+ self.context,
+ embed,
+ prefix=description,
+ max_lines=COMMANDS_PER_PAGE,
+ max_size=2040,
+ )
- 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 send_bot_help(self, mapping: dict) -> None:
+ """Sends help for all bot commands and cogs."""
+ bot = self.context.bot
- 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)
+ embed = Embed()
+ embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark)
+
+ filter_commands = await self.filter_commands(bot.commands, sort=True, key=self._category_key)
+
+ cog_or_category_pages = []
+
+ for cog_or_category, _commands in itertools.groupby(filter_commands, key=self._category_key):
+ sorted_commands = sorted(_commands, key=lambda c: c.name)
+
+ if len(sorted_commands) == 0:
+ continue
+
+ command_detail_lines = self.get_commands_brief_details(sorted_commands, return_as_list=True)
+
+ # Split cogs or categories which have too many commands to fit in one page.
+ # The length of commands is included for later use when aggregating into pages for the paginator.
+ for index in range(0, len(sorted_commands), COMMANDS_PER_PAGE):
+ truncated_lines = command_detail_lines[index:index + COMMANDS_PER_PAGE]
+ joined_lines = "".join(truncated_lines)
+ cog_or_category_pages.append((f"**{cog_or_category}**{joined_lines}", len(truncated_lines)))
+
+ pages = []
+ counter = 0
+ page = ""
+ for page_details, length in cog_or_category_pages:
+ counter += length
+ if counter > COMMANDS_PER_PAGE:
+ # force a new page on paginator even if it falls short of the max pages
+ # since we still want to group categories/cogs.
+ counter = length
+ pages.append(page)
+ page = f"{page_details}\n\n"
+ else:
+ page += f"{page_details}\n\n"
- 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)
+ if page:
+ # add any remaining command help that didn't get added in the last iteration above.
+ pages.append(page)
- async def do_stop(self) -> None:
- """Event that is called when the user requests to stop the help session."""
- await self.message.delete()
+ await LinePaginator.paginate(pages, self.context, embed=embed, max_lines=1, max_size=2040)
-class Help(DiscordCog):
+class Help(Cog):
"""Custom Embed Pagination Help feature."""
- @commands.command('help')
- @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_ROLES)
- 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 __init__(self, bot: Bot) -> None:
+ self.bot = bot
+ self.old_help_command = bot.help_command
+ bot.help_command = CustomHelpCommand()
+ bot.help_command.cog = self
-def unload(bot: Bot) -> 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 cog_unload(self) -> None:
+ """Reset the help command when the cog is unloaded."""
+ self.bot.help_command = self.old_help_command
def setup(bot: Bot) -> 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: Bot) -> 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)
+ """Load the Help cog."""
+ bot.add_cog(Help(bot))
+ log.info("Cog loaded: Help")
diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py
index b714a1642..1bd1f9d68 100644
--- a/bot/cogs/help_channels.py
+++ b/bot/cogs/help_channels.py
@@ -225,7 +225,7 @@ class HelpChannels(Scheduler, commands.Cog):
return role_check
- @commands.command(name="close", aliases=["dormant"], enabled=False)
+ @commands.command(name="close", aliases=["dormant", "solved"], enabled=False)
async def close_command(self, ctx: commands.Context) -> None:
"""
Make the current in-use help channel dormant.
diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py
index 250a24247..edfdfd9e2 100644
--- a/bot/cogs/moderation/management.py
+++ b/bot/cogs/moderation/management.py
@@ -43,7 +43,7 @@ class ModManagement(commands.Cog):
@commands.group(name='infraction', aliases=('infr', 'infractions', 'inf'), invoke_without_command=True)
async def infraction_group(self, ctx: Context) -> None:
"""Infraction manipulation commands."""
- await ctx.invoke(self.bot.get_command("help"), "infraction")
+ await ctx.send_help(ctx.command)
@infraction_group.command(name='edit')
async def infraction_edit(
diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py
index 81511f99d..201579a0b 100644
--- a/bot/cogs/off_topic_names.py
+++ b/bot/cogs/off_topic_names.py
@@ -97,7 +97,7 @@ class OffTopicNames(Cog):
@with_role(*MODERATION_ROLES)
async def otname_group(self, ctx: Context) -> None:
"""Add or list items from the off-topic channel name rotation."""
- await ctx.invoke(self.bot.get_command("help"), "otname")
+ await ctx.send_help(ctx.command)
@otname_group.command(name='add', aliases=('a',))
@with_role(*MODERATION_ROLES)
diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py
index 5a7fa100f..3b77538a0 100644
--- a/bot/cogs/reddit.py
+++ b/bot/cogs/reddit.py
@@ -218,7 +218,10 @@ class Reddit(Cog):
for subreddit in RedditConfig.subreddits:
top_posts = await self.get_top_posts(subreddit=subreddit, time="day")
- await self.webhook.send(username=f"{subreddit} Top Daily Posts", embed=top_posts)
+ message = await self.webhook.send(username=f"{subreddit} Top Daily Posts", embed=top_posts, wait=True)
+
+ if message.channel.is_news():
+ await message.publish()
async def top_weekly_posts(self) -> None:
"""Post a summary of the top posts."""
@@ -242,10 +245,13 @@ class Reddit(Cog):
await message.pin()
+ if message.channel.is_news():
+ await message.publish()
+
@group(name="reddit", invoke_without_command=True)
async def reddit_group(self, ctx: Context) -> None:
"""View the top posts from various subreddits."""
- await ctx.invoke(self.bot.get_command("help"), "reddit")
+ await ctx.send_help(ctx.command)
@reddit_group.command(name="top")
async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None:
diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py
index 8b6457cbb..c242d2920 100644
--- a/bot/cogs/reminders.py
+++ b/bot/cogs/reminders.py
@@ -281,7 +281,7 @@ class Reminders(Scheduler, Cog):
@remind_group.group(name="edit", aliases=("change", "modify"), invoke_without_command=True)
async def edit_reminder_group(self, ctx: Context) -> None:
"""Commands for modifying your current reminders."""
- await ctx.invoke(self.bot.get_command("help"), "reminders", "edit")
+ await ctx.send_help(ctx.command)
@edit_reminder_group.command(name="duration", aliases=("time",))
async def edit_reminder_duration(self, ctx: Context, id_: int, expiration: Duration) -> None:
diff --git a/bot/cogs/site.py b/bot/cogs/site.py
index 853e29568..7fc2a9c34 100644
--- a/bot/cogs/site.py
+++ b/bot/cogs/site.py
@@ -21,7 +21,7 @@ class Site(Cog):
@group(name="site", aliases=("s",), invoke_without_command=True)
async def site_group(self, ctx: Context) -> None:
"""Commands for getting info about our website."""
- await ctx.invoke(self.bot.get_command("help"), "site")
+ await ctx.send_help(ctx.command)
@site_group.command(name="home", aliases=("about",))
async def site_main(self, ctx: Context) -> None:
diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py
index 9fa75a929..6ef659f28 100644
--- a/bot/cogs/snekbox.py
+++ b/bot/cogs/snekbox.py
@@ -290,7 +290,7 @@ class Snekbox(Cog):
return
if not code: # None or empty string
- await ctx.invoke(self.bot.get_command("help"), "eval")
+ await ctx.send_help(ctx.command)
return
log.info(f"Received code from {ctx.author} for evaluation:\n{code}")
diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py
index 89d556f58..6b59d37c8 100644
--- a/bot/cogs/utils.py
+++ b/bot/cogs/utils.py
@@ -55,7 +55,7 @@ class Utils(Cog):
if pep_number.isdigit():
pep_number = int(pep_number)
else:
- await ctx.invoke(self.bot.get_command("help"), "pep")
+ await ctx.send_help(ctx.command)
return
# Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs.
diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py
index 903c87f85..e4fb173e0 100644
--- a/bot/cogs/watchchannels/bigbrother.py
+++ b/bot/cogs/watchchannels/bigbrother.py
@@ -30,7 +30,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
@with_role(*MODERATION_ROLES)
async def bigbrother_group(self, ctx: Context) -> None:
"""Monitors users by relaying their messages to the Big Brother watch channel."""
- await ctx.invoke(self.bot.get_command("help"), "bigbrother")
+ await ctx.send_help(ctx.command)
@bigbrother_group.command(name='watched', aliases=('all', 'list'))
@with_role(*MODERATION_ROLES)
diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py
index ad0c51fa6..9a85c68c2 100644
--- a/bot/cogs/watchchannels/talentpool.py
+++ b/bot/cogs/watchchannels/talentpool.py
@@ -34,7 +34,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
@with_role(*MODERATION_ROLES)
async def nomination_group(self, ctx: Context) -> None:
"""Highlights the activity of helper nominees by relaying their messages to the talent pool channel."""
- await ctx.invoke(self.bot.get_command("help"), "talentpool")
+ await ctx.send_help(ctx.command)
@nomination_group.command(name='watched', aliases=('all', 'list'))
@with_role(*MODERATION_ROLES)
@@ -173,7 +173,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
@with_role(*MODERATION_ROLES)
async def nomination_edit_group(self, ctx: Context) -> None:
"""Commands to edit nominations."""
- await ctx.invoke(self.bot.get_command("help"), "talentpool", "edit")
+ await ctx.send_help(ctx.command)
@nomination_edit_group.command(name='reason')
@with_role(*MODERATION_ROLES)
diff --git a/bot/cogs/wolfram.py b/bot/cogs/wolfram.py
index 5d6b4630b..e6cae3bb8 100644
--- a/bot/cogs/wolfram.py
+++ b/bot/cogs/wolfram.py
@@ -60,6 +60,14 @@ def custom_cooldown(*ignore: List[int]) -> Callable:
A list of roles may be provided to ignore the per-user cooldown
"""
async def predicate(ctx: Context) -> bool:
+ if ctx.invoked_with == 'help':
+ # if the invoked command is help we don't want to increase the ratelimits since it's not actually
+ # invoking the command/making a request, so instead just check if the user/guild are on cooldown.
+ guild_cooldown = not guildcd.get_bucket(ctx.message).get_tokens() == 0 # if guild is on cooldown
+ if not any(r.id in ignore for r in ctx.author.roles): # check user bucket if user is not ignored
+ return guild_cooldown and not usercd.get_bucket(ctx.message).get_tokens() == 0
+ return guild_cooldown
+
user_bucket = usercd.get_bucket(ctx.message)
if all(role.id not in ignore for role in ctx.author.roles):
diff --git a/bot/decorators.py b/bot/decorators.py
index 2ee5879f2..306f0830c 100644
--- a/bot/decorators.py
+++ b/bot/decorators.py
@@ -1,6 +1,6 @@
import logging
import random
-from asyncio import Lock, sleep
+from asyncio import Lock, create_task, sleep
from contextlib import suppress
from functools import wraps
from typing import Callable, Container, Optional, Union
@@ -162,13 +162,12 @@ def redirect_output(destination_channel: int, bypass_roles: Container[int] = Non
log.trace(f"Redirecting output of {ctx.author}'s command '{ctx.command.name}' to {redirect_channel.name}")
ctx.channel = redirect_channel
await ctx.channel.send(f"Here's the output of your command, {ctx.author.mention}")
- await func(self, ctx, *args, **kwargs)
+ create_task(func(self, ctx, *args, **kwargs))
message = await old_channel.send(
f"Hey, {ctx.author.mention}, you can find the output of your command here: "
f"{redirect_channel.mention}"
)
-
if RedirectOutput.delete_invocation:
await sleep(RedirectOutput.delete_delay)
@@ -179,6 +178,7 @@ def redirect_output(destination_channel: int, bypass_roles: Container[int] = Non
with suppress(NotFound):
await ctx.message.delete()
log.trace("Redirect output: Deleted invocation message")
+
return inner
return wrap
diff --git a/bot/pagination.py b/bot/pagination.py
index 90c8f849c..b0c4b70e2 100644
--- a/bot/pagination.py
+++ b/bot/pagination.py
@@ -102,7 +102,7 @@ class LinePaginator(Paginator):
timeout: int = 300,
footer_text: str = None,
url: str = None,
- exception_on_empty_embed: bool = False
+ exception_on_empty_embed: bool = False,
) -> t.Optional[discord.Message]:
"""
Use a paginator and set of reactions to provide pagination over a set of lines.
diff --git a/bot/resources/tags/mutability.md b/bot/resources/tags/mutability.md
new file mode 100644
index 000000000..bde9b5e7e
--- /dev/null
+++ b/bot/resources/tags/mutability.md
@@ -0,0 +1,37 @@
+**Mutable vs immutable objects**
+
+Imagine that you want to make all letters in a string upper case. Conveniently, strings have an `.upper()` method.
+
+You might think that this would work:
+```python
+>>> greeting = "hello"
+>>> greeting.upper()
+'HELLO'
+>>> greeting
+'hello'
+```
+
+`greeting` didn't change. Why is that so?
+
+That's because strings in Python are _immutable_. You can't change them, you can only pass around existing strings or create new ones.
+
+```python
+>>> greeting = "hello"
+>>> greeting = greeting.upper()
+>>> greeting
+'HELLO'
+```
+
+`greeting.upper()` creates and returns a new string which is like the old one, but with all the letters turned to upper case.
+
+`int`, `float`, `complex`, `tuple`, `frozenset` are other examples of immutable data types in Python.
+
+Mutable data types like `list`, on the other hand, can be changed in-place:
+```python
+>>> my_list = [1, 2, 3]
+>>> my_list.append(4)
+>>> my_list
+[1, 2, 3, 4]
+```
+
+Other examples of mutable data types in Python are `dict` and `set`. Instances of user-defined classes are also mutable.
diff --git a/config-default.yml b/config-default.yml
index 83ea59016..c0b5b062f 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -316,6 +316,8 @@ filter:
- poweredbydialup.online
- poweredbysecurity.org
- poweredbysecurity.online
+ - ssteam.site
+ - steamwalletgift.com
word_watchlist:
- goo+ks*
diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py
index ccc090f02..14299e766 100644
--- a/tests/bot/cogs/test_snekbox.py
+++ b/tests/bot/cogs/test_snekbox.py
@@ -208,10 +208,9 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
async def test_eval_command_call_help(self):
"""Test if the eval command call the help command if no code is provided."""
- ctx = MockContext()
- ctx.invoke = AsyncMock()
+ ctx = MockContext(command="sentinel")
await self.cog.eval_command.callback(self.cog, ctx=ctx, code='')
- ctx.invoke.assert_called_once_with(self.bot.get_command("help"), "eval")
+ ctx.send_help.assert_called_once_with("sentinel")
async def test_send_eval(self):
"""Test the send_eval function."""