Skip to content

Commit

Permalink
feat(help): add slash command support
Browse files Browse the repository at this point in the history
  • Loading branch information
ZRunner committed Nov 24, 2023
1 parent dd69b64 commit 6f027d4
Show file tree
Hide file tree
Showing 6 changed files with 332 additions and 37 deletions.
26 changes: 23 additions & 3 deletions fcts/help_cmd.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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))
2 changes: 2 additions & 0 deletions libs/help_cmd/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
)
159 changes: 159 additions & 0 deletions libs/help_cmd/help_slash.py
Original file line number Diff line number Diff line change
@@ -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.<locals>.predicate' in str(check):
check_name = 'guild_only'
elif 'is_owner.<locals>.predicate' in str(check):
check_name = 'is_owner'
elif 'bot_has_permissions.<locals>.predicate' in str(check):
check_name = 'bot_has_permissions'
elif '_has_permissions.<locals>.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
}
113 changes: 113 additions & 0 deletions libs/help_cmd/slash_cmd_utils.py
Original file line number Diff line number Diff line change
@@ -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)
Loading

0 comments on commit 6f027d4

Please sign in to comment.