Add initial support for app command localisation

This commit is contained in:
Rapptz
2022-08-02 10:05:32 -04:00
parent eb3bc7102b
commit 2d586ae805
16 changed files with 663 additions and 91 deletions

View File

@@ -48,10 +48,11 @@ from textwrap import TextWrapper
import re
from ..enums import AppCommandOptionType, AppCommandType
from ..enums import AppCommandOptionType, AppCommandType, Locale
from .models import Choice
from .transformers import annotation_to_parameter, CommandParameter, NoneType
from .errors import AppCommandError, CheckFailure, CommandInvokeError, CommandSignatureMismatch, CommandAlreadyRegistered
from .translator import TranslationContext, Translator, locale_str
from ..message import Message
from ..user import User
from ..member import Member
@@ -281,18 +282,21 @@ def _populate_descriptions(params: Dict[str, CommandParameter], descriptions: Di
param.description = ''
continue
if not isinstance(description, str):
if not isinstance(description, (str, locale_str)):
raise TypeError('description must be a string')
param.description = _shorten(description)
if isinstance(description, str):
param.description = _shorten(description)
else:
param.description = description
if descriptions:
first = next(iter(descriptions))
raise TypeError(f'unknown parameter given: {first}')
def _populate_renames(params: Dict[str, CommandParameter], renames: Dict[str, str]) -> None:
rename_map: Dict[str, str] = {}
def _populate_renames(params: Dict[str, CommandParameter], renames: Dict[str, Union[str, locale_str]]) -> None:
rename_map: Dict[str, Union[str, locale_str]] = {}
# original name to renamed name
@@ -306,7 +310,11 @@ def _populate_renames(params: Dict[str, CommandParameter], renames: Dict[str, st
if name in rename_map:
raise ValueError(f'{new_name} is already used')
new_name = validate_name(new_name)
if isinstance(new_name, str):
new_name = validate_name(new_name)
else:
validate_name(new_name.message)
rename_map[name] = new_name
params[name]._rename = new_name
@@ -449,6 +457,12 @@ def _get_context_menu_parameter(func: ContextMenuCallback) -> Tuple[str, Any, Ap
return (parameter.name, resolved, type)
async def _get_translation_payload(
command: Union[Command[Any, ..., Any], Group, ContextMenu], translator: Translator
) -> Dict[str, Any]:
...
class Command(Generic[GroupT, P, T]):
"""A class that implements an application command.
@@ -464,10 +478,12 @@ class Command(Generic[GroupT, P, T]):
Attributes
------------
name: :class:`str`
The name of the application command.
The name of the application command. When passed as an argument
to ``__init__`` this can also be :class:`locale_str`.
description: :class:`str`
The description of the application command. This shows up in the UI to describe
the application command.
the application command. When passed as an argument to ``__init__`` this
can also be :class:`locale_str`.
checks
A list of predicates that take a :class:`~discord.Interaction` parameter
to indicate whether the command callback should be executed. If an exception
@@ -501,16 +517,22 @@ class Command(Generic[GroupT, P, T]):
def __init__(
self,
*,
name: str,
description: str,
name: Union[str, locale_str],
description: Union[str, locale_str],
callback: CommandCallback[GroupT, P, T],
nsfw: bool = False,
parent: Optional[Group] = None,
guild_ids: Optional[List[int]] = None,
extras: Dict[Any, Any] = MISSING,
):
name, locale = (name.message, name) if isinstance(name, locale_str) else (name, None)
self.name: str = validate_name(name)
self._locale_name: Optional[locale_str] = locale
description, locale = (
(description.message, description) if isinstance(description, locale_str) else (description, None)
)
self.description: str = description
self._locale_description: Optional[locale_str] = locale
self._attr: Optional[str] = None
self._callback: CommandCallback[GroupT, P, T] = callback
self.parent: Optional[Group] = parent
@@ -561,9 +583,11 @@ class Command(Generic[GroupT, P, T]):
cls = self.__class__
copy = cls.__new__(cls)
copy.name = self.name
copy._locale_name = self._locale_name
copy._guild_ids = self._guild_ids
copy.checks = self.checks
copy.description = self.description
copy._locale_description = self._locale_description
copy.default_permissions = self.default_permissions
copy.guild_only = self.guild_only
copy.nsfw = self.nsfw
@@ -581,6 +605,28 @@ class Command(Generic[GroupT, P, T]):
return copy
async def get_translated_payload(self, translator: Translator) -> Dict[str, Any]:
base = self.to_dict()
name_localizations: Dict[str, str] = {}
description_localizations: Dict[str, str] = {}
for locale in Locale:
if self._locale_name:
translation = await translator._checked_translate(self._locale_name, locale, TranslationContext.command_name)
if translation is not None:
name_localizations[locale.value] = translation
if self._locale_description:
translation = await translator._checked_translate(
self._locale_description, locale, TranslationContext.command_description
)
if translation is not None:
description_localizations[locale.value] = translation
base['name_localizations'] = name_localizations
base['description_localizations'] = description_localizations
base['options'] = [await param.get_translated_payload(translator) for param in self._params.values()]
return base
def to_dict(self) -> Dict[str, Any]:
# If we have a parent then our type is a subcommand
# Otherwise, the type falls back to the specific command type (e.g. slash command or context menu)
@@ -929,7 +975,8 @@ class ContextMenu:
Attributes
------------
name: :class:`str`
The name of the context menu.
The name of the context menu. When passed as an argument to ``__init__``
this can be :class:`locale_str`.
type: :class:`.AppCommandType`
The type of context menu application command. By default, this is inferred
by the parameter of the callback.
@@ -958,14 +1005,16 @@ class ContextMenu:
def __init__(
self,
*,
name: str,
name: Union[str, locale_str],
callback: ContextMenuCallback,
type: AppCommandType = MISSING,
nsfw: bool = False,
guild_ids: Optional[List[int]] = None,
extras: Dict[Any, Any] = MISSING,
):
name, locale = (name.message, name) if isinstance(name, locale_str) else (name, None)
self.name: str = validate_context_menu_name(name)
self._locale_name: Optional[locale_str] = locale
self._callback: ContextMenuCallback = callback
(param, annotation, actual_type) = _get_context_menu_parameter(callback)
if type is MISSING:
@@ -998,6 +1047,18 @@ class ContextMenu:
""":class:`str`: Returns the fully qualified command name."""
return self.name
async def get_translated_payload(self, translator: Translator) -> Dict[str, Any]:
base = self.to_dict()
if self._locale_name:
name_localizations: Dict[str, str] = {}
for locale in Locale:
translation = await translator._checked_translate(self._locale_name, locale, TranslationContext.command_name)
if translation is not None:
name_localizations[locale.value] = translation
base['name_localizations'] = name_localizations
return base
def to_dict(self) -> Dict[str, Any]:
return {
'name': self.name,
@@ -1107,11 +1168,13 @@ class Group:
------------
name: :class:`str`
The name of the group. If not given, it defaults to a lower-case
kebab-case version of the class name.
kebab-case version of the class name. When passed as an argument to
``__init__`` or the class this can be :class:`locale_str`.
description: :class:`str`
The description of the group. This shows up in the UI to describe
the group. If not given, it defaults to the docstring of the
class shortened to 100 characters.
class shortened to 100 characters. When passed as an argument to
``__init__`` or the class this can be :class:`locale_str`.
default_permissions: Optional[:class:`~discord.Permissions`]
The default permissions that can execute this group on Discord. Note
that server administrators can override this value in the client.
@@ -1140,6 +1203,8 @@ class Group:
__discord_app_commands_skip_init_binding__: bool = False
__discord_app_commands_group_name__: str = MISSING
__discord_app_commands_group_description__: str = MISSING
__discord_app_commands_group_locale_name__: Optional[locale_str] = None
__discord_app_commands_group_locale_description__: Optional[locale_str] = None
__discord_app_commands_group_nsfw__: bool = False
__discord_app_commands_guild_only__: bool = MISSING
__discord_app_commands_default_permissions__: Optional[Permissions] = MISSING
@@ -1151,8 +1216,8 @@ class Group:
def __init_subclass__(
cls,
*,
name: str = MISSING,
description: str = MISSING,
name: Union[str, locale_str] = MISSING,
description: Union[str, locale_str] = MISSING,
guild_only: bool = MISSING,
nsfw: bool = False,
default_permissions: Optional[Permissions] = MISSING,
@@ -1175,16 +1240,22 @@ class Group:
if name is MISSING:
cls.__discord_app_commands_group_name__ = validate_name(_to_kebab_case(cls.__name__))
else:
elif isinstance(name, str):
cls.__discord_app_commands_group_name__ = validate_name(name)
else:
cls.__discord_app_commands_group_name__ = validate_name(name.message)
cls.__discord_app_commands_group_locale_name__ = name
if description is MISSING:
if cls.__doc__ is None:
cls.__discord_app_commands_group_description__ = ''
else:
cls.__discord_app_commands_group_description__ = _shorten(cls.__doc__)
else:
elif isinstance(description, str):
cls.__discord_app_commands_group_description__ = description
else:
cls.__discord_app_commands_group_description__ = description.message
cls.__discord_app_commands_group_locale_description__ = description
if guild_only is not MISSING:
cls.__discord_app_commands_guild_only__ = guild_only
@@ -1199,8 +1270,8 @@ class Group:
def __init__(
self,
*,
name: str = MISSING,
description: str = MISSING,
name: Union[str, locale_str] = MISSING,
description: Union[str, locale_str] = MISSING,
parent: Optional[Group] = None,
guild_ids: Optional[List[int]] = None,
guild_only: bool = MISSING,
@@ -1209,8 +1280,28 @@ class Group:
extras: Dict[Any, Any] = MISSING,
):
cls = self.__class__
self.name: str = validate_name(name) if name is not MISSING else cls.__discord_app_commands_group_name__
self.description: str = description or cls.__discord_app_commands_group_description__
if name is MISSING:
name, locale = cls.__discord_app_commands_group_name__, cls.__discord_app_commands_group_locale_name__
elif isinstance(name, str):
name, locale = validate_name(name), None
else:
name, locale = validate_name(name.message), name
self.name: str = name
self._locale_name: Optional[locale_str] = locale
if description is MISSING:
description, locale = (
cls.__discord_app_commands_group_description__,
cls.__discord_app_commands_group_locale_description__,
)
elif isinstance(description, str):
description, locale = description, None
else:
description, locale = description.message, description
self.description: str = description
self._locale_description: Optional[locale_str] = locale
self._attr: Optional[str] = None
self._owner_cls: Optional[Type[Any]] = None
self._guild_ids: Optional[List[int]] = guild_ids or getattr(cls, '__discord_app_commands_default_guilds__', None)
@@ -1291,8 +1382,10 @@ class Group:
cls = self.__class__
copy = cls.__new__(cls)
copy.name = self.name
copy._locale_name = self._locale_name
copy._guild_ids = self._guild_ids
copy.description = self.description
copy._locale_description = self._locale_description
copy.parent = parent
copy.module = self.module
copy.default_permissions = self.default_permissions
@@ -1321,6 +1414,28 @@ class Group:
return copy
async def get_translated_payload(self, translator: Translator) -> Dict[str, Any]:
base = self.to_dict()
name_localizations: Dict[str, str] = {}
description_localizations: Dict[str, str] = {}
for locale in Locale:
if self._locale_name:
translation = await translator._checked_translate(self._locale_name, locale, TranslationContext.command_name)
if translation is not None:
name_localizations[locale.value] = translation
if self._locale_description:
translation = await translator._checked_translate(
self._locale_description, locale, TranslationContext.command_description
)
if translation is not None:
description_localizations[locale.value] = translation
base['name_localizations'] = name_localizations
base['description_localizations'] = description_localizations
base['options'] = [await child.get_translated_payload(translator) for child in self._children.values()]
return base
def to_dict(self) -> Dict[str, Any]:
# If this has a parent command then it's part of a subcommand group
# Otherwise, it's just a regular command
@@ -1535,8 +1650,8 @@ class Group:
def command(
self,
*,
name: str = MISSING,
description: str = MISSING,
name: Union[str, locale_str] = MISSING,
description: Union[str, locale_str] = MISSING,
nsfw: bool = False,
extras: Dict[Any, Any] = MISSING,
) -> Callable[[CommandCallback[GroupT, P, T]], Command[GroupT, P, T]]:
@@ -1544,10 +1659,10 @@ class Group:
Parameters
------------
name: :class:`str`
name: Union[:class:`str`, :class:`locale_str`]
The name of the application command. If not given, it defaults to a lower-case
version of the callback name.
description: :class:`str`
description: Union[:class:`str`, :class:`locale_str`]
The description of the application command. This shows up in the UI to describe
the application command. If not given, it defaults to the first line of the docstring
of the callback shortened to 100 characters.
@@ -1586,8 +1701,8 @@ class Group:
def command(
*,
name: str = MISSING,
description: str = MISSING,
name: Union[str, locale_str] = MISSING,
description: Union[str, locale_str] = MISSING,
nsfw: bool = False,
extras: Dict[Any, Any] = MISSING,
) -> Callable[[CommandCallback[GroupT, P, T]], Command[GroupT, P, T]]:
@@ -1637,7 +1752,7 @@ def command(
def context_menu(
*,
name: str = MISSING,
name: Union[str, locale_str] = MISSING,
nsfw: bool = False,
extras: Dict[Any, Any] = MISSING,
) -> Callable[[ContextMenuCallback], ContextMenu]:
@@ -1662,7 +1777,7 @@ def context_menu(
Parameters
------------
name: :class:`str`
name: Union[:class:`str`, :class:`locale_str`]
The name of the context menu command. If not given, it defaults to a title-case
version of the callback name. Note that unlike regular slash commands this can
have spaces and upper case characters in the name.
@@ -1685,7 +1800,7 @@ def context_menu(
return decorator
def describe(**parameters: str) -> Callable[[T], T]:
def describe(**parameters: Union[str, locale_str]) -> Callable[[T], T]:
r"""Describes the given parameters by their name using the key of the keyword argument
as the name.
@@ -1700,7 +1815,7 @@ def describe(**parameters: str) -> Callable[[T], T]:
Parameters
-----------
\*\*parameters
\*\*parameters: Union[:class:`str`, :class:`locale_str`]
The description of the parameters.
Raises
@@ -1723,7 +1838,7 @@ def describe(**parameters: str) -> Callable[[T], T]:
return decorator
def rename(**parameters: str) -> Callable[[T], T]:
def rename(**parameters: Union[str, locale_str]) -> Callable[[T], T]:
r"""Renames the given parameters by their name using the key of the keyword argument
as the name.
@@ -1741,7 +1856,7 @@ def rename(**parameters: str) -> Callable[[T], T]:
Parameters
-----------
\*\*parameters
\*\*parameters: Union[:class:`str`, :class:`locale_str`]
The name of the parameters.
Raises