diff --git a/fcts/help_cmd.py b/fcts/help_cmd.py index 89f8b9a24..df226c13b 100644 --- a/fcts/help_cmd.py +++ b/fcts/help_cmd.py @@ -1,12 +1,13 @@ import json -from typing import Any, Callable, Coroutine, Optional, TypedDict +from typing import Any, Callable, Coroutine, Optional, TypedDict, Union import discord +from discord.app_commands import Command, Group from discord.ext import commands from libs.bot_classes import Axobot, MyContext from libs.help_cmd import (help_all_command, help_category_command, - help_text_cmd_command) + help_text_cmd_command, help_slash_cmd_command) from libs.help_cmd.utils import get_send_callback @@ -70,10 +71,14 @@ async def help_command(self, ctx: MyContext, command_arg: list[str]): if category_id := await self._detect_category_from_args(ctx, command_arg): await help_category_command(self, ctx, category_id) return - # if user entered a command / subcommand name + # if user entered a textual or hybrid command / subcommand name if command := self.bot.get_command(" ".join(command_arg)): await help_text_cmd_command(self, ctx, command) return + # if user entered a slash command / subcommand name + if command := await self._find_command_from_name(command_arg, None): + await help_slash_cmd_command(self, ctx, command) + return send = await get_send_callback(ctx) await self._send_error_unknown_command(ctx, send, command_arg) @@ -105,6 +110,21 @@ async def _send_error_unknown_command(self, ctx: MyContext, send: Callable[..., return await self._send_error_unknown_command(ctx, send, parent) + async def _find_command_from_name(self, args: list[str], parent_command: Union[Command, None] + ) -> Union[Command, Group, None]: + if parent_command and not args: + return parent_command + if not args: + return None + current_arg, args = args[0], args[1:] + if not parent_command: + if cmd := self.bot.tree.get_command(current_arg): + return await self._find_command_from_name(args, cmd) + elif isinstance(parent_command, Group): + for subcommand in parent_command.commands: + if subcommand.name == current_arg: + return await self._find_command_from_name(args, subcommand) + async def setup(bot): await bot.add_cog(Help(bot)) diff --git a/libs/help_cmd/__init__.py b/libs/help_cmd/__init__.py index 309466db2..5288d78c5 100644 --- a/libs/help_cmd/__init__.py +++ b/libs/help_cmd/__init__.py @@ -1,9 +1,11 @@ from .help_all import help_all_command from .help_category import help_category_command from .help_cmd import help_text_cmd_command +from .help_slash import help_slash_cmd_command __all__ = ( "help_all_command", "help_category_command", "help_text_cmd_command", + "help_slash_cmd_command", ) diff --git a/libs/help_cmd/help_slash.py b/libs/help_cmd/help_slash.py new file mode 100644 index 000000000..ab278aeaf --- /dev/null +++ b/libs/help_cmd/help_slash.py @@ -0,0 +1,159 @@ +from typing import TYPE_CHECKING, Optional, Union + +import discord +from discord.app_commands import Command, Group + +from libs.bot_classes import MyContext + +from .slash_cmd_utils import (get_command_desc_translation, + get_command_description, + get_command_name_translation, + get_command_signature) +from .utils import (FieldData, get_embed_color, get_embed_footer, + get_send_callback) + +if TYPE_CHECKING: + from fcts.help_cmd import Help as HelpCog + +AppCommandOrGroup = Union[Command, Group] + +def sort_by_name(cmd: AppCommandOrGroup): + return cmd.name + + +async def help_slash_cmd_command(cog: "HelpCog", ctx: MyContext, command: AppCommandOrGroup): + "Generate embed fields to describe usage of one command or commands group" + send = await get_send_callback(ctx) + syntax, fields = await _generate_command_fields(cog, ctx, command) + embed_color = get_embed_color(ctx) + embed = discord.Embed(title=syntax, color=embed_color) + embed.set_footer(text=await get_embed_footer(ctx)) + for field in fields: + embed.add_field(**field) + await send(embed=embed) + + +async def _generate_command_fields(cog: "HelpCog", ctx: MyContext, command: AppCommandOrGroup): + "Generate embed fields to describe usage of one command or commands group" + fields: list[FieldData] = [] + desc, examples, doc = await get_command_description(ctx, command) + # Syntax + syntax = await get_command_signature(ctx, command) + # Description + fields.append({ + "name": await ctx.bot._(ctx.channel, "help.description"), + "value": desc, + "inline": False + }) + # Examples + if examples: + fields.append({ + "name": (await ctx.bot._(ctx.channel, "misc.example", count=len(examples))).capitalize(), + "value": "\n".join(examples), + "inline": False + }) + # Subcommands + if isinstance(command, Group): + syntax += " ..." + if subcommands_field := await _generate_subcommands_field(ctx, command): + fields.append(subcommands_field) + # Disabled and checks + if warnings_field := await _generate_warnings_field(ctx, command): + fields.append(warnings_field) + # Documentation URL + if doc is not None: + doc_url = cog.doc_url + doc + fields.append({ + "name": (await ctx.bot._(ctx.channel, "misc.doc")).capitalize(), + "value": doc_url, + "inline": False + }) + # Category + fields.append(await _generate_command_category_field(cog, ctx, command)) + return syntax, fields + +async def _generate_subcommands_field(ctx: MyContext, cmd: Group) -> Optional[FieldData]: + "Generate an embed field to describe the subcommands of a commands group" + subcmds = "" + subs_cant_show = 0 + explored_subcommands = [] + for subcommand in sorted(cmd.commands, key=sort_by_name): + if subcommand.name not in explored_subcommands: + if len(subcmds) > 950: + subs_cant_show += 1 + else: + name = await get_command_name_translation(ctx, subcommand) + if (description := await get_command_desc_translation(ctx, subcommand)) is None: + description = subcommand.description.split('\n')[0].strip() + desc = f"*({description})*" if len(description) > 0 else "" + subcmds += f"\nā€¢ {name} {desc}" + explored_subcommands.append(subcommand.name) + if subs_cant_show > 0: + subcmds += "\n" + await ctx.bot._(ctx.channel, 'help.more-subcmds', count=subs_cant_show) + if len(subcmds) > 0: + return { + "name": await ctx.bot._(ctx.channel, "help.subcmds"), + "value": subcmds, + "inline": False + } + +async def _generate_warnings_field(ctx: MyContext, command: AppCommandOrGroup) -> Optional[FieldData]: + "Generate an embed field to list warnings and checks about a command usage" + if isinstance(command, Group): + return None + warnings: list[str] = [] + if len(command.checks) > 0: + maybe_coro = discord.utils.maybe_coroutine + for check in command.checks: + try: + if 'guild_only..predicate' in str(check): + check_name = 'guild_only' + elif 'is_owner..predicate' in str(check): + check_name = 'is_owner' + elif 'bot_has_permissions..predicate' in str(check): + check_name = 'bot_has_permissions' + elif '_has_permissions..predicate' in str(check): + check_name = 'has_permissions' + else: + check_name = check.__name__ + check_msg_tr = await ctx.bot._(ctx.channel, f'help.check-desc.{check_name}') + if 'help.check-desc' not in check_msg_tr: + if ctx.interaction: + try: + pass_check = await maybe_coro(check, ctx.interaction) + except Exception: + pass_check = False + if pass_check: + warnings.append( + "āœ… " + check_msg_tr[0]) + else: + warnings.append('āŒ ' + check_msg_tr[1]) + else: + warnings.append('- ' + check_msg_tr[1]) + else: + ctx.bot.dispatch("error", ValueError(f"No description for help check {check_name} ({check})")) + except Exception as err: + ctx.bot.dispatch("error", err, f"While checking {check} in help") + if warnings: + return { + "name": await ctx.bot._(ctx.channel, "help.warning"), + "value": "\n".join(warnings), + "inline": False + } + +async def _generate_command_category_field(cog: "HelpCog", ctx: MyContext, command: AppCommandOrGroup) -> FieldData: + "Generate an embed field to describe the category of a command" + category = "unclassed" + for key, data in cog.commands_data.items(): + categ_commands = data['commands'] + root_name = command.root_parent.name if command.root_parent else command.name + if root_name in categ_commands: + category = key + break + emoji = cog.commands_data[category]['emoji'] + category = emoji + " " + (await cog.bot._(ctx.channel, f"help.categories.{category}")).capitalize() + return { + "name": await ctx.bot._(ctx.channel, "misc.category"), + "value": category, + "inline": False + } diff --git a/libs/help_cmd/slash_cmd_utils.py b/libs/help_cmd/slash_cmd_utils.py new file mode 100644 index 000000000..1b7a06d02 --- /dev/null +++ b/libs/help_cmd/slash_cmd_utils.py @@ -0,0 +1,113 @@ +from typing import Optional, Union + +from discord import Locale +from discord.app_commands import Argument as AppArgument +from discord.app_commands import Command, Group +from discord.app_commands.translator import (TranslationContext, + TranslationContextLocation, + locale_str) + +from libs.bot_classes import MyContext + +from .utils import extract_info, get_discord_locale + +AppCommandOrGroup = Union[Command, Group] + + +async def get_command_inline_desc(ctx: MyContext, cmd: AppCommandOrGroup): + "Generate a 1-line description with the command name and short desc" + name = await get_command_name_translation(ctx, cmd) + short = await get_command_desc_translation(ctx, cmd) or cmd.description.split('\n')[0].strip() + return f"ā€¢ **{name}**" + (f" *{short}*" if short else "") + + +async def get_command_description(ctx: MyContext, command: AppCommandOrGroup): + "Get the parsed description of a command" + if isinstance(command, Group): + raw_desc = command.description.strip() + else: + raw_desc = command.callback.__doc__ or "" + desc = Optional[str] + desc, examples, doc = await extract_info(raw_desc) + # check for translated description + if short_desc := await get_command_desc_translation(ctx, command): + if len(desc.split('\n')) > 1: + long_desc = '\n'.join(desc.split('\n')[1:]).strip() + desc = f"{short_desc}\n\n{long_desc}" + if desc is None: + desc = await ctx.bot._(ctx.channel, "help.no-desc-cmd") + return desc, examples, doc + +async def get_command_signature(ctx: MyContext, command: AppCommandOrGroup): + "Get the signature of a command" + # name + translated_name = await get_command_full_name_translation(ctx, command) + # parameters + signature = await _get_command_params_signature(ctx, command) + return f"/{translated_name} {signature}".strip() + + +async def get_command_full_name_translation(ctx: MyContext, command: AppCommandOrGroup): + "Get the translated command or group name (with parent name if exists)" + locale = await get_discord_locale(ctx) + full_name = await get_command_name_translation(ctx, command, locale) + while command.parent is not None: + full_name = await get_command_name_translation(ctx, command.parent, locale) + " " + full_name + command = command.parent + return full_name + +async def get_command_name_translation(ctx: MyContext, command: AppCommandOrGroup, locale: Optional[Locale]=None): + "Get the translated command or group name (without parent name)" + locale = locale or await get_discord_locale(ctx) + if isinstance(command, Group): + context = TranslationContext( + TranslationContextLocation.group_name, + command + ) + else: + context = TranslationContext( + TranslationContextLocation.command_name, + command + ) + if translation := await ctx.bot.tree.translator.translate(locale_str(""), locale, context): + return translation + return command.qualified_name + +async def get_command_desc_translation(ctx: MyContext, command: AppCommandOrGroup): + "Get the translated command or group description" + locale = await get_discord_locale(ctx) + if isinstance(command, Group): + context = TranslationContext( + TranslationContextLocation.group_description, + command + ) + else: + context = TranslationContext( + TranslationContextLocation.command_description, + command + ) + return await ctx.bot.tree.translator.translate(locale_str(""), locale, context) + + +async def _get_command_param_translation(ctx: MyContext, param: AppArgument): + "Get the translated command parameter name" + locale = await get_discord_locale(ctx) + context = TranslationContext( + TranslationContextLocation.parameter_name, + param + ) + return await ctx.bot.tree.translator.translate(locale_str(param.name), locale, context) or param.name + +async def _get_command_params_signature(ctx: MyContext, command: AppCommandOrGroup): + "Returns a POSIX-like signature useful for help command output." + if isinstance(command, Group) or not command.parameters: + return '' + result = [] + for param in command.parameters: + name = await _get_command_param_translation(ctx, param) + if param.required: + result.append(f'<{name}>') + else: + result.append(f'[{name}]') + + return ' '.join(result) diff --git a/libs/help_cmd/txt_cmd_utils.py b/libs/help_cmd/txt_cmd_utils.py index 54903498e..5c1afa582 100644 --- a/libs/help_cmd/txt_cmd_utils.py +++ b/libs/help_cmd/txt_cmd_utils.py @@ -8,7 +8,8 @@ from discord.ext import commands from libs.bot_classes import MyContext -from libs.translator import LOCALES_MAP + +from .utils import extract_info, get_discord_locale async def get_command_inline_desc(ctx: MyContext, cmd: commands.Command): @@ -24,7 +25,7 @@ async def get_command_description(ctx: MyContext, command: commands.Command): if raw_desc == '' and command.help is not None: raw_desc = command.help.strip() desc = Optional[str] - desc, examples, doc = await _extract_info(raw_desc) + desc, examples, doc = await extract_info(raw_desc) # check for translated description if short_desc := await get_command_desc_translation(ctx, command): if len(desc.split('\n')) > 1: @@ -49,17 +50,9 @@ async def get_command_signature(ctx: MyContext, command: commands.Command): signature = await _get_command_params_signature(ctx, command) return f"{prefix}{translated_name} {signature}".strip() - -async def _get_discord_locale(ctx: MyContext): - bot_locale = await ctx.bot._(ctx.channel, "_used_locale") - for locale, lang in LOCALES_MAP.items(): - if lang == bot_locale: - return locale - return Locale.british_english - async def get_command_full_name_translation(ctx: MyContext, command: commands.Command): "Get the translated command or group name (with parent name if exists)" - locale = await _get_discord_locale(ctx) + locale = await get_discord_locale(ctx) full_name = await get_command_name_translation(ctx, command, locale) while command.parent is not None: full_name = await get_command_name_translation(ctx, command.parent, locale) + " " + full_name @@ -68,7 +61,7 @@ async def get_command_full_name_translation(ctx: MyContext, command: commands.Co async def get_command_name_translation(ctx: MyContext, command: commands.Command, locale: Optional[Locale]=None): "Get the translated command or group name (without parent name)" - locale = locale or await _get_discord_locale(ctx) + locale = locale or await get_discord_locale(ctx) if isinstance(command, commands.Group): context = TranslationContext( TranslationContextLocation.group_name, @@ -79,11 +72,11 @@ async def get_command_name_translation(ctx: MyContext, command: commands.Command TranslationContextLocation.command_name, command ) - return await ctx.bot.tree.translator.translate(locale_str(""), locale, context) or command.qualified_name + return await ctx.bot.tree.translator.translate(locale_str(""), locale, context) or command.name async def get_command_desc_translation(ctx: MyContext, command: commands.Command): "Get the translated command or group description" - locale = await _get_discord_locale(ctx) + locale = await get_discord_locale(ctx) if isinstance(command, commands.Group): context = TranslationContext( TranslationContextLocation.group_description, @@ -96,27 +89,9 @@ async def get_command_desc_translation(ctx: MyContext, command: commands.Command ) return await ctx.bot.tree.translator.translate(locale_str(""), locale, context) - -async def _extract_info(raw_description: str) -> tuple[Optional[str], list[str], Optional[str]]: - "Split description, examples and documentation link from the given documentation" - description, examples = [], [] - doc = "" - for line in raw_description.split("\n\n"): - line = line.strip() - if line.startswith("..Example "): - examples.append(line.replace("..Example ", "")) - elif line.startswith("..Doc "): - doc = line.replace("..Doc ", "") - else: - description.append(line) - return ( - x if len(x) > 0 else None - for x in ("\n\n".join(description), examples, doc) - ) - async def _get_command_param_translation(ctx: MyContext, param: commands.Parameter, command: commands.HybridCommand): "Get the translated command parameter name" - locale = await _get_discord_locale(ctx) + locale = await get_discord_locale(ctx) class FakeParameter: def __init__(self, command: Union[AppCommand, commands.HybridCommand]): self.command = command diff --git a/libs/help_cmd/utils.py b/libs/help_cmd/utils.py index 29fa150f2..7b2d6abfb 100644 --- a/libs/help_cmd/utils.py +++ b/libs/help_cmd/utils.py @@ -1,8 +1,9 @@ -from typing import Optional, TypedDict +from typing import Optional, TypedDict, Union import discord from libs.bot_classes import MyContext +from libs.translator import LOCALES_MAP class FieldData(TypedDict): @@ -25,6 +26,31 @@ async def get_embed_footer(ctx: MyContext): prefix = "/" return ft.format(prefix) +async def get_discord_locale(ctx: Union[MyContext, discord.Interaction]): + "Get the Discord locale to use for a given context" + bot_locale = await ctx.bot._(ctx.channel, "_used_locale") + for locale, lang in LOCALES_MAP.items(): + if lang == bot_locale: + return locale + return discord.Locale.british_english + +async def extract_info(raw_description: str) -> tuple[Optional[str], list[str], Optional[str]]: + "Split description, examples and documentation link from the given documentation" + description, examples = [], [] + doc = "" + for line in raw_description.split("\n\n"): + line = line.strip() + if line.startswith("..Example "): + examples.append(line.replace("..Example ", "")) + elif line.startswith("..Doc "): + doc = line.replace("..Doc ", "") + else: + description.append(line) + return ( + x if len(x) > 0 else None + for x in ("\n\n".join(description), examples, doc) + ) + async def get_send_callback(ctx: MyContext): "Get a function to call to send the command result" async def _send_interaction(content: Optional[str]=None, *, embed: Optional[discord.Embed] = None):