diff options
| -rw-r--r-- | bot/cogs/bot.py | 2 | ||||
| -rw-r--r-- | bot/cogs/clean.py | 2 | ||||
| -rw-r--r-- | bot/cogs/defcon.py | 2 | ||||
| -rw-r--r-- | bot/cogs/error_handler.py | 33 | ||||
| -rw-r--r-- | bot/cogs/eval.py | 2 | ||||
| -rw-r--r-- | bot/cogs/extensions.py | 8 | ||||
| -rw-r--r-- | bot/cogs/help.py | 713 | ||||
| -rw-r--r-- | bot/cogs/help_channels.py | 2 | ||||
| -rw-r--r-- | bot/cogs/moderation/management.py | 2 | ||||
| -rw-r--r-- | bot/cogs/off_topic_names.py | 2 | ||||
| -rw-r--r-- | bot/cogs/reddit.py | 10 | ||||
| -rw-r--r-- | bot/cogs/reminders.py | 2 | ||||
| -rw-r--r-- | bot/cogs/site.py | 2 | ||||
| -rw-r--r-- | bot/cogs/snekbox.py | 5 | ||||
| -rw-r--r-- | bot/cogs/utils.py | 2 | ||||
| -rw-r--r-- | bot/cogs/watchchannels/bigbrother.py | 2 | ||||
| -rw-r--r-- | bot/cogs/watchchannels/talentpool.py | 4 | ||||
| -rw-r--r-- | bot/cogs/wolfram.py | 8 | ||||
| -rw-r--r-- | bot/decorators.py | 6 | ||||
| -rw-r--r-- | bot/pagination.py | 2 | ||||
| -rw-r--r-- | bot/resources/tags/mutability.md | 37 | ||||
| -rw-r--r-- | config-default.yml | 2 | ||||
| -rw-r--r-- | tests/bot/cogs/test_snekbox.py | 11 | 
23 files changed, 356 insertions, 505 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 8d4688114..6ef659f28 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -47,6 +47,7 @@ EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles  SIGKILL = 9  REEVAL_EMOJI = '\U0001f501'  # :repeat: +REEVAL_TIMEOUT = 30  class Snekbox(Cog): @@ -227,7 +228,7 @@ class Snekbox(Cog):                  _, new_message = await self.bot.wait_for(                      'message_edit',                      check=_predicate_eval_message_edit, -                    timeout=10 +                    timeout=REEVAL_TIMEOUT                  )                  await ctx.message.add_reaction(REEVAL_EMOJI)                  await self.bot.wait_for( @@ -289,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 1dec0ccaf..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.""" @@ -291,7 +290,11 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):          self.assertEqual(actual, expected)          self.bot.wait_for.assert_has_awaits(              ( -                call('message_edit', check=partial_mock(snekbox.predicate_eval_message_edit, ctx), timeout=10), +                call( +                    'message_edit', +                    check=partial_mock(snekbox.predicate_eval_message_edit, ctx), +                    timeout=snekbox.REEVAL_TIMEOUT, +                ),                  call('reaction_add', check=partial_mock(snekbox.predicate_eval_emoji_reaction, ctx), timeout=10)              )          ) | 
