Repository: python-discord/bot

Issue:
Suggest a list of available commands in case of typos
When we try to invoke a command, we can make little typos, for example
`!muet @shirayuki 72H oh come on, cant you make a proper issue?`

In this case, the bot can then steps in and do a friendly suggestions:
`did you perhaps mean`
`!mute @shirayuki 72H oh come on, cant you make a proper issue?`

The typo in this example was `!muet` when trying to do `!mute`

This will greatly relieve the stress of typing every command rights, and thus enhancing the experience with the bot much further.


Discussion:
We should include a [`Command.can_run`](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Command.can_run) check to make sure the individual is actually allowed to run the command within the given context.
We could also include misspelt arguments, but that's another different way of handling typos since it is different for every command
I would love to work on this in the future :+1: 

Resolving patch:
diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py
--- a/bot/exts/backend/error_handler.py
+++ b/bot/exts/backend/error_handler.py
@@ -1,4 +1,5 @@
 import contextlib
+import difflib
 import logging
 import typing as t
 
@@ -8,7 +9,7 @@
 
 from bot.api import ResponseCodeError
 from bot.bot import Bot
-from bot.constants import Colours
+from bot.constants import Colours, Icons, MODERATION_ROLES
 from bot.converters import TagNameConverter
 from bot.errors import LockedResourceError
 from bot.utils.checks import InWhitelistCheckFailure
@@ -155,9 +156,46 @@ async def try_get_tag(self, ctx: Context) -> None:
         else:
             with contextlib.suppress(ResponseCodeError):
                 await ctx.invoke(tags_get_command, tag_name=tag_name)
+
+        if not any(role.id in MODERATION_ROLES for role in ctx.author.roles):
+            tags_cog = self.bot.get_cog("Tags")
+            command_name = ctx.invoked_with
+            sent = await tags_cog.display_tag(ctx, command_name)
+
+            if not sent:
+                await self.send_command_suggestion(ctx, command_name)
+
         # Return to not raise the exception
         return
 
+    async def send_command_suggestion(self, ctx: Context, command_name: str) -> None:
+        """Sends user similar commands if any can be found."""
+        # No similar tag found, or tag on cooldown -
+        # searching for a similar command
+        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)
+
+            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)
+                    return
+            except errors.CommandError as cmd_error:
+                log.debug(log_msg)
+                await self.on_command_error(ctx, cmd_error)
+                return
+
+            misspelled_content = ctx.message.content
+            e = Embed()
+            e.set_author(name="Did you mean:", icon_url=Icons.questionmark)
+            e.description = f"{misspelled_content.replace(command_name, similar_command_name, 1)}"
+            await ctx.send(embed=e, delete_after=10.0)
+
     async def handle_user_input_error(self, ctx: Context, e: errors.UserInputError) -> None:
         """
         Send an error message in `ctx` for UserInputError, sometimes invoking the help command too.
diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py
--- a/bot/exts/info/tags.py
+++ b/bot/exts/info/tags.py
@@ -182,10 +182,15 @@ async def search_tag_content_any_keyword(self, ctx: Context, *, keywords: Option
         matching_tags = self._get_tags_via_content(any, keywords or 'any', ctx.author)
         await self._send_matching_tags(ctx, keywords, matching_tags)
 
-    @tags_group.command(name='get', aliases=('show', 'g'))
-    async def get_command(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None:
-        """Get a specified tag, or a list of all tags if no tag is specified."""
+    async def display_tag(self, ctx: Context, tag_name: str = None) -> bool:
+        """
+        If a tag is not found, display similar tag names as suggestions.
 
+        If a tag is not specified, display a paginated embed of all tags.
+
+        Tags are on cooldowns on a per-tag, per-channel basis. If a tag is on cooldown, display
+        nothing and return False.
+        """
         def _command_on_cooldown(tag_name: str) -> bool:
             """
             Check if the command is currently on cooldown, on a per-tag, per-channel basis.
@@ -212,7 +217,7 @@ def _command_on_cooldown(tag_name: str) -> bool:
                 f"{ctx.author} tried to get the '{tag_name}' tag, but the tag is on cooldown. "
                 f"Cooldown ends in {time_left:.1f} seconds."
             )
-            return
+            return False
 
         if tag_name is not None:
             temp_founds = self._get_tag(tag_name)
@@ -237,6 +242,7 @@ def _command_on_cooldown(tag_name: str) -> bool:
                     await ctx.send(embed=Embed.from_dict(tag['embed'])),
                     [ctx.author.id],
                 )
+                return True
             elif founds and len(tag_name) >= 3:
                 await wait_for_deletion(
                     await ctx.send(
@@ -247,6 +253,7 @@ def _command_on_cooldown(tag_name: str) -> bool:
                     ),
                     [ctx.author.id],
                 )
+                return True
 
         else:
             tags = self._cache.values()
@@ -255,6 +262,7 @@ def _command_on_cooldown(tag_name: str) -> bool:
                     description="**There are no tags in the database!**",
                     colour=Colour.red()
                 ))
+                return True
             else:
                 embed: Embed = Embed(title="**Current tags**")
                 await LinePaginator.paginate(
@@ -268,6 +276,14 @@ def _command_on_cooldown(tag_name: str) -> bool:
                     empty=False,
                     max_lines=15
                 )
+                return True
+
+        return False
+
+    @tags_group.command(name='get', aliases=('show', 'g'))
+    async def get_command(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None:
+        """Get a specified tag, or a list of all tags if no tag is specified."""
+        await self.display_tag(ctx, tag_name)
 
 
 def setup(bot: Bot) -> None:


Test patch: