diff options
| author | 2022-08-18 17:52:36 +0100 | |
|---|---|---|
| committer | 2022-08-18 17:52:36 +0100 | |
| commit | 96f84a147c5e862d6153f48b2de83613aa3bd654 (patch) | |
| tree | 6962c76ff7b9a9b8b732e5663b807e4a3d70f2de | |
| parent | add more packages to .latex (diff) | |
| parent | Help command fix, normalize suggestions for unknown commands (#1064) (diff) | |
Merge branch 'main' into feat/latex-enhancement
| -rw-r--r-- | bot/exts/core/error_handler.py | 43 | ||||
| -rw-r--r-- | bot/exts/core/help.py | 40 | ||||
| -rw-r--r-- | bot/exts/fun/fun.py | 78 | ||||
| -rw-r--r-- | bot/exts/fun/uwu.py | 35 | ||||
| -rw-r--r-- | bot/utils/commands.py | 11 | ||||
| -rw-r--r-- | bot/utils/messages.py | 72 | 
6 files changed, 160 insertions, 119 deletions
diff --git a/bot/exts/core/error_handler.py b/bot/exts/core/error_handler.py index 983632ba..4578f734 100644 --- a/bot/exts/core/error_handler.py +++ b/bot/exts/core/error_handler.py @@ -1,4 +1,3 @@ -import difflib  import logging  import math  import random @@ -11,6 +10,7 @@ from sentry_sdk import push_scope  from bot.bot import Bot  from bot.constants import Channels, Colours, ERROR_REPLIES, NEGATIVE_REPLIES, RedirectOutput +from bot.utils.commands import get_command_suggestions  from bot.utils.decorators import InChannelCheckFailure, InMonthCheckFailure  from bot.utils.exceptions import APIError, MovedCommandError, UserNotPlayingError @@ -158,31 +158,32 @@ class CommandErrorHandler(commands.Cog):      async def send_command_suggestion(self, ctx: commands.Context, command_name: str) -> None:          """Sends user similar commands if any can be found.""" -        raw_commands = [] -        for cmd in self.bot.walk_commands(): -            if not cmd.hidden: -                raw_commands += (cmd.name, *cmd.aliases) -        if similar_command_data := difflib.get_close_matches(command_name, raw_commands, 1): -            similar_command_name = similar_command_data[0] -            similar_command = self.bot.get_command(similar_command_name) - -            if not similar_command: -                return - -            log_msg = "Cancelling attempt to suggest a command due to failed checks." -            try: -                if not await similar_command.can_run(ctx): +        command_suggestions = [] +        if similar_command_names := get_command_suggestions(list(self.bot.all_commands.keys()), command_name): +            for similar_command_name in similar_command_names: +                similar_command = self.bot.get_command(similar_command_name) + +                if not similar_command: +                    continue + +                log_msg = "Cancelling attempt to suggest a command due to failed checks." +                try: +                    if not await similar_command.can_run(ctx): +                        log.debug(log_msg) +                        continue +                except commands.errors.CommandError as cmd_error:                      log.debug(log_msg) -                    return -            except commands.errors.CommandError as cmd_error: -                log.debug(log_msg) -                await self.on_command_error(ctx, cmd_error) -                return +                    await self.on_command_error(ctx, cmd_error) +                    continue + +                command_suggestions.append(similar_command_name)              misspelled_content = ctx.message.content              e = Embed()              e.set_author(name="Did you mean:", icon_url=QUESTION_MARK_ICON) -            e.description = misspelled_content.replace(command_name, similar_command_name, 1) +            e.description = "\n".join( +                misspelled_content.replace(command_name, cmd, 1) for cmd in command_suggestions +            )              await ctx.send(embed=e, delete_after=RedirectOutput.delete_delay) diff --git a/bot/exts/core/help.py b/bot/exts/core/help.py index f9b3513f..b5df70ca 100644 --- a/bot/exts/core/help.py +++ b/bot/exts/core/help.py @@ -3,16 +3,16 @@ import asyncio  import itertools  import logging  from contextlib import suppress -from typing import NamedTuple, Union +from typing import NamedTuple, Optional, 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 rapidfuzz import process  from bot import constants  from bot.bot import Bot  from bot.constants import Emojis +from bot.utils.commands import get_command_suggestions  from bot.utils.pagination import FIRST_EMOJI, LAST_EMOJI, LEFT_EMOJI, LinePaginator, RIGHT_EMOJI  DELETE_EMOJI = Emojis.trashcan @@ -41,14 +41,18 @@ class HelpQueryNotFound(ValueError):      """      Raised when a HelpSession Query doesn't match a command or cog. -    Contains the custom attribute of ``possible_matches``. -    Instances of this object contain a dictionary of any command(s) that were close to matching the -    query, where keys are the possible matched command names and values are the likeness match scores. +    Params: +    possible_matches: list of similar command names. +    parent_command: parent command of an invalid subcommand. Only available when an invalid subcommand +                    has been passed.      """ -    def __init__(self, arg: str, possible_matches: dict = None): +    def __init__( +            self, arg: str, possible_matches: Optional[list[str]] = None, *, parent_command: Optional[Command] = None +    ) -> None:          super().__init__(arg)          self.possible_matches = possible_matches +        self.parent_command = parent_command  class HelpSession: @@ -153,12 +157,17 @@ class HelpSession:          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) +        # Check if parent command is valid in case subcommand is invalid. +        if " " in query: +            parent, *_ = query.split() +            parent_command = self._bot.get_command(parent) + +            if parent_command: +                raise HelpQueryNotFound('Invalid Subcommand.', parent_command=parent_command) -        result = process.extract(query, choices, score_cutoff=90) +        similar_commands = get_command_suggestions(list(self._bot.all_commands.keys()), query) -        raise HelpQueryNotFound(f'Query "{query}" not found.', dict(result)) +        raise HelpQueryNotFound(f'Query "{query}" not found.', similar_commands)      async def timeout(self, seconds: int = 30) -> None:          """Waits for a set number of seconds, then stops the help session.""" @@ -507,13 +516,20 @@ class Help(DiscordCog):          try:              await HelpSession.start(ctx, *commands)          except HelpQueryNotFound as error: + +            # Send help message of parent command if subcommand is invalid. +            if cmd := error.parent_command: +                await ctx.send(str(error)) +                await self.new_help(ctx, cmd.qualified_name) +                return +              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}`" +                matches = "\n".join(error.possible_matches) +                embed.description = f"**Did you mean:**\n{matches}"              await ctx.send(embed=embed) diff --git a/bot/exts/fun/fun.py b/bot/exts/fun/fun.py index 9ec9b9ee..e7337cb6 100644 --- a/bot/exts/fun/fun.py +++ b/bot/exts/fun/fun.py @@ -3,16 +3,16 @@ import logging  import random  from collections.abc import Iterable  from pathlib import Path -from typing import Callable, Literal, Optional, Union +from typing import Literal  import pyjokes -from discord import Embed, Message +from discord import Embed  from discord.ext import commands -from discord.ext.commands import BadArgument, Cog, Context, MessageConverter, clean_content +from discord.ext.commands import BadArgument, Cog, Context, clean_content  from bot.bot import Bot  from bot.constants import Client, Colours, Emojis -from bot.utils import helpers +from bot.utils import helpers, messages  log = logging.getLogger(__name__) @@ -67,10 +67,10 @@ class Fun(Cog):              return "".join(                  char.upper() if round(random.random()) else char.lower() for char in text              ) -        text, embed = await Fun._get_text_and_embed(ctx, text) +        text, embed = await messages.get_text_and_embed(ctx, text)          # Convert embed if it exists          if embed is not None: -            embed = Fun._convert_embed(conversion_func, embed) +            embed = messages.convert_embed(conversion_func, embed)          converted_text = conversion_func(text)          converted_text = helpers.suppress_links(converted_text)          # Don't put >>> if only embed present @@ -116,10 +116,10 @@ class Fun(Cog):              """Encrypts the given string using the Caesar Cipher."""              return "".join(caesar_cipher(text, offset)) -        text, embed = await Fun._get_text_and_embed(ctx, msg) +        text, embed = await messages.get_text_and_embed(ctx, msg)          if embed is not None: -            embed = Fun._convert_embed(conversion_func, embed) +            embed = messages.convert_embed(conversion_func, embed)          converted_text = conversion_func(text) @@ -150,68 +150,6 @@ class Fun(Cog):          """          await self._caesar_cipher(ctx, offset, msg, left_shift=True) -    @staticmethod -    async def _get_text_and_embed(ctx: Context, text: str) -> tuple[str, Optional[Embed]]: -        """ -        Attempts to extract the text and embed from a possible link to a discord Message. - -        Does not retrieve the text and embed from the Message if it is in a channel the user does -        not have read permissions in. - -        Returns a tuple of: -            str: If `text` is a valid discord Message, the contents of the message, else `text`. -            Optional[Embed]: The embed if found in the valid Message, else None -        """ -        embed = None - -        msg = await Fun._get_discord_message(ctx, text) -        # Ensure the user has read permissions for the channel the message is in -        if isinstance(msg, Message): -            permissions = msg.channel.permissions_for(ctx.author) -            if permissions.read_messages: -                text = msg.clean_content -                # Take first embed because we can't send multiple embeds -                if msg.embeds: -                    embed = msg.embeds[0] - -        return (text, embed) - -    @staticmethod -    async def _get_discord_message(ctx: Context, text: str) -> Union[Message, str]: -        """ -        Attempts to convert a given `text` to a discord Message object and return it. - -        Conversion will succeed if given a discord Message ID or link. -        Returns `text` if the conversion fails. -        """ -        try: -            text = await MessageConverter().convert(ctx, text) -        except commands.BadArgument: -            log.debug(f"Input '{text:.20}...' is not a valid Discord Message") -        return text - -    @staticmethod -    def _convert_embed(func: Callable[[str, ], str], embed: Embed) -> Embed: -        """ -        Converts the text in an embed using a given conversion function, then return the embed. - -        Only modifies the following fields: title, description, footer, fields -        """ -        embed_dict = embed.to_dict() - -        embed_dict["title"] = func(embed_dict.get("title", "")) -        embed_dict["description"] = func(embed_dict.get("description", "")) - -        if "footer" in embed_dict: -            embed_dict["footer"]["text"] = func(embed_dict["footer"].get("text", "")) - -        if "fields" in embed_dict: -            for field in embed_dict["fields"]: -                field["name"] = func(field.get("name", "")) -                field["value"] = func(field.get("value", "")) - -        return Embed.from_dict(embed_dict) -      @commands.command()      async def joke(self, ctx: commands.Context, category: Literal["neutral", "chuck", "all"] = "all") -> None:          """Retrieves a joke of the specified `category` from the pyjokes api.""" diff --git a/bot/exts/fun/uwu.py b/bot/exts/fun/uwu.py index 81e81b36..83497893 100644 --- a/bot/exts/fun/uwu.py +++ b/bot/exts/fun/uwu.py @@ -9,10 +9,7 @@ from discord.ext import commands  from discord.ext.commands import Cog, Context, clean_content  from bot.bot import Bot -from bot.utils import helpers - -if t.TYPE_CHECKING: -    from bot.exts.fun.fun import Fun  # pragma: no cover +from bot.utils import helpers, messages  WORD_REPLACE = {      "small": "smol", @@ -169,7 +166,10 @@ class Uwu(Cog):          # If `text` isn't provided then we try to get message content of a replied message          text = text or getattr(ctx.message.reference, "resolved", None)          if isinstance(text, discord.Message): +            embeds = text.embeds              text = text.content +        else: +            embeds = None          if text is None:              # If we weren't able to get the content of a replied message @@ -177,20 +177,25 @@ class Uwu(Cog):          await clean_content(fix_channel_mentions=True).convert(ctx, text) -        fun_cog: t.Optional[Fun] = ctx.bot.get_cog("Fun") -        if fun_cog: -            text, embed = await fun_cog._get_text_and_embed(ctx, text) - -            # Grabs the text from the embed for uwuification. -            if embed is not None: -                embed = fun_cog._convert_embed(self._uwuify, embed) +        # Grabs the text from the embed for uwuification +        if embeds: +            embed = messages.convert_embed(self._uwuify, embeds[0])          else: -            embed = None -        converted_text = self._uwuify(text) -        converted_text = helpers.suppress_links(converted_text) +            # Parse potential message links in text +            text, embed = await messages.get_text_and_embed(ctx, text) + +            # If an embed is found, grab and uwuify its text +            if embed: +                embed = messages.convert_embed(self._uwuify, embed)          # Adds the text harvested from an embed to be put into another quote block. -        converted_text = f">>> {converted_text.lstrip('> ')}" +        if text: +            converted_text = self._uwuify(text) +            converted_text = helpers.suppress_links(converted_text) +            converted_text = f">>> {converted_text.lstrip('> ')}" +        else: +            converted_text = None +          await ctx.send(content=converted_text, embed=embed) diff --git a/bot/utils/commands.py b/bot/utils/commands.py new file mode 100644 index 00000000..7c04a25a --- /dev/null +++ b/bot/utils/commands.py @@ -0,0 +1,11 @@ +from typing import Optional + +from rapidfuzz import process + + +def get_command_suggestions( +        all_commands: list[str], query: str, *, cutoff: int = 60, limit: int = 3 +) -> Optional[list]: +    """Get similar command names.""" +    results = process.extract(query, all_commands, score_cutoff=cutoff, limit=limit) +    return [result[0] for result in results] diff --git a/bot/utils/messages.py b/bot/utils/messages.py index a6c035f9..b0c95583 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -1,5 +1,12 @@ +import logging  import re -from typing import Optional +from typing import Callable, Optional, Union + +from discord import Embed, Message +from discord.ext import commands +from discord.ext.commands import Context, MessageConverter + +log = logging.getLogger(__name__)  def sub_clyde(username: Optional[str]) -> Optional[str]: @@ -17,3 +24,66 @@ def sub_clyde(username: Optional[str]) -> Optional[str]:          return re.sub(r"(clyd)(e)", replace_e, username, flags=re.I)      else:          return username  # Empty string or None + + +async def get_discord_message(ctx: Context, text: str) -> Union[Message, str]: +    """ +    Attempts to convert a given `text` to a discord Message object and return it. + +    Conversion will succeed if given a discord Message ID or link. +    Returns `text` if the conversion fails. +    """ +    try: +        text = await MessageConverter().convert(ctx, text) +    except commands.BadArgument: +        pass + +    return text + + +async def get_text_and_embed(ctx: Context, text: str) -> tuple[str, Optional[Embed]]: +    """ +    Attempts to extract the text and embed from a possible link to a discord Message. + +    Does not retrieve the text and embed from the Message if it is in a channel the user does +    not have read permissions in. + +    Returns a tuple of: +        str: If `text` is a valid discord Message, the contents of the message, else `text`. +        Optional[Embed]: The embed if found in the valid Message, else None +    """ +    embed: Optional[Embed] = None + +    msg = await get_discord_message(ctx, text) +    # Ensure the user has read permissions for the channel the message is in +    if isinstance(msg, Message): +        permissions = msg.channel.permissions_for(ctx.author) +        if permissions.read_messages: +            text = msg.clean_content +            # Take first embed because we can't send multiple embeds +            if msg.embeds: +                embed = msg.embeds[0] + +    return text, embed + + +def convert_embed(func: Callable[[str, ], str], embed: Embed) -> Embed: +    """ +    Converts the text in an embed using a given conversion function, then return the embed. + +    Only modifies the following fields: title, description, footer, fields +    """ +    embed_dict = embed.to_dict() + +    embed_dict["title"] = func(embed_dict.get("title", "")) +    embed_dict["description"] = func(embed_dict.get("description", "")) + +    if "footer" in embed_dict: +        embed_dict["footer"]["text"] = func(embed_dict["footer"].get("text", "")) + +    if "fields" in embed_dict: +        for field in embed_dict["fields"]: +            field["name"] = func(field.get("name", "")) +            field["value"] = func(field.get("value", "")) + +    return Embed.from_dict(embed_dict)  |