aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--bot/exts/core/error_handler.py43
-rw-r--r--bot/exts/core/help.py40
-rw-r--r--bot/exts/fun/fun.py78
-rw-r--r--bot/exts/fun/uwu.py35
-rw-r--r--bot/utils/commands.py11
-rw-r--r--bot/utils/messages.py72
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)