mirror of
https://github.com/Rapptz/discord.py.git
synced 2025-10-24 18:13:00 +00:00
Add initial support for app command localisation
This commit is contained in:
@@ -15,5 +15,6 @@ from .models import *
|
||||
from .tree import *
|
||||
from .namespace import *
|
||||
from .transformers import *
|
||||
from .translator import *
|
||||
from . import checks as checks
|
||||
from .checks import Cooldown as Cooldown
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -27,13 +27,14 @@ from __future__ import annotations
|
||||
from typing import Any, TYPE_CHECKING, List, Optional, Union
|
||||
|
||||
|
||||
from ..enums import AppCommandOptionType, AppCommandType
|
||||
from ..enums import AppCommandOptionType, AppCommandType, Locale
|
||||
from ..errors import DiscordException
|
||||
|
||||
__all__ = (
|
||||
'AppCommandError',
|
||||
'CommandInvokeError',
|
||||
'TransformerError',
|
||||
'TranslationError',
|
||||
'CheckFailure',
|
||||
'CommandAlreadyRegistered',
|
||||
'CommandSignatureMismatch',
|
||||
@@ -51,6 +52,7 @@ __all__ = (
|
||||
if TYPE_CHECKING:
|
||||
from .commands import Command, Group, ContextMenu
|
||||
from .transformers import Transformer
|
||||
from .translator import TranslationContext, locale_str
|
||||
from ..types.snowflake import Snowflake, SnowflakeList
|
||||
from .checks import Cooldown
|
||||
|
||||
@@ -133,6 +135,50 @@ class TransformerError(AppCommandError):
|
||||
super().__init__(f'Failed to convert {value} to {transformer._error_display_name!s}')
|
||||
|
||||
|
||||
class TranslationError(AppCommandError):
|
||||
"""An exception raised when the library fails to translate a string.
|
||||
|
||||
This inherits from :exc:`~discord.app_commands.AppCommandError`.
|
||||
|
||||
If an exception occurs while calling :meth:`Translator.translate` that does
|
||||
not subclass this then the exception is wrapped into this exception.
|
||||
The original exception can be retrieved using the ``__cause__`` attribute.
|
||||
Otherwise it will be propagated as-is.
|
||||
|
||||
.. versionadded:: 2.0
|
||||
|
||||
Attributes
|
||||
-----------
|
||||
string: Optional[Union[:class:`str`, :class:`locale_str`]]
|
||||
The string that caused the error, if any.
|
||||
locale: Optional[:class:`~discord.Locale`]
|
||||
The locale that caused the error, if any.
|
||||
context: :class:`~discord.app_commands.TranslationContext`
|
||||
The context of the translation that triggered the error.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*msg: str,
|
||||
string: Optional[Union[str, locale_str]] = None,
|
||||
locale: Optional[Locale] = None,
|
||||
context: TranslationContext,
|
||||
) -> None:
|
||||
self.string: Optional[Union[str, locale_str]] = string
|
||||
self.locale: Optional[Locale] = locale
|
||||
self.context: TranslationContext = context
|
||||
|
||||
if msg:
|
||||
super().__init__(*msg)
|
||||
else:
|
||||
ctx = context.name.replace('_', ' ')
|
||||
fmt = f'Failed to translate {self.string!r} in a {ctx}'
|
||||
if self.locale is not None:
|
||||
fmt = f'{fmt} in the {self.locale.value} locale'
|
||||
|
||||
super().__init__(fmt)
|
||||
|
||||
|
||||
class CheckFailure(AppCommandError):
|
||||
"""An exception raised when check predicates in a command have failed.
|
||||
|
||||
|
||||
@@ -26,8 +26,9 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
|
||||
from .errors import MissingApplicationID
|
||||
from .translator import Translator, TranslationContext, locale_str
|
||||
from ..permissions import Permissions
|
||||
from ..enums import AppCommandOptionType, AppCommandType, AppCommandPermissionType, ChannelType, try_enum
|
||||
from ..enums import AppCommandOptionType, AppCommandType, AppCommandPermissionType, ChannelType, Locale, try_enum
|
||||
from ..mixins import Hashable
|
||||
from ..utils import _get_as_snowflake, parse_time, snowflake_time, MISSING
|
||||
from ..object import Object
|
||||
@@ -56,7 +57,6 @@ def is_app_command_argument_type(value: int) -> bool:
|
||||
if TYPE_CHECKING:
|
||||
from ..types.command import (
|
||||
ApplicationCommand as ApplicationCommandPayload,
|
||||
ApplicationCommandOptionChoice,
|
||||
ApplicationCommandOption,
|
||||
ApplicationCommandPermissions,
|
||||
GuildApplicationCommandPermissions,
|
||||
@@ -407,17 +407,22 @@ class Choice(Generic[ChoiceT]):
|
||||
|
||||
Parameters
|
||||
-----------
|
||||
name: :class:`str`
|
||||
name: Union[:class:`str`, :class:`locale_str`]
|
||||
The name of the choice. Used for display purposes.
|
||||
name_localizations: Dict[:class:`~discord.Locale`, :class:`str`]
|
||||
The localised names of the choice. Used for display purposes.
|
||||
value: Union[:class:`int`, :class:`str`, :class:`float`]
|
||||
The value of the choice.
|
||||
"""
|
||||
|
||||
__slots__ = ('name', 'value')
|
||||
__slots__ = ('name', 'value', '_locale_name', 'name_localizations')
|
||||
|
||||
def __init__(self, *, name: str, value: ChoiceT):
|
||||
def __init__(self, *, name: Union[str, locale_str], value: ChoiceT, name_localizations: Dict[Locale, str] = MISSING):
|
||||
name, locale = (name.message, name) if isinstance(name, locale_str) else (name, None)
|
||||
self.name: str = name
|
||||
self._locale_name: Optional[locale_str] = locale
|
||||
self.value: ChoiceT = value
|
||||
self.name_localizations: Dict[Locale, str] = MISSING
|
||||
|
||||
def __eq__(self, o: object) -> bool:
|
||||
return isinstance(o, Choice) and self.name == o.name and self.value == o.value
|
||||
@@ -441,11 +446,28 @@ class Choice(Generic[ChoiceT]):
|
||||
f'invalid Choice value type given, expected int, str, or float but received {self.value.__class__!r}'
|
||||
)
|
||||
|
||||
def to_dict(self) -> ApplicationCommandOptionChoice:
|
||||
return { # type: ignore
|
||||
async def get_translated_payload(self, translator: Translator) -> Dict[str, Any]:
|
||||
base = self.to_dict()
|
||||
name_localizations: Dict[str, str] = {}
|
||||
if self._locale_name:
|
||||
for locale in Locale:
|
||||
translation = await translator._checked_translate(self._locale_name, locale, TranslationContext.choice_name)
|
||||
if translation is not None:
|
||||
name_localizations[locale.value] = translation
|
||||
|
||||
if name_localizations:
|
||||
base['name_localizations'] = name_localizations
|
||||
|
||||
return base
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
base = {
|
||||
'name': self.name,
|
||||
'value': self.value,
|
||||
}
|
||||
if self.name_localizations is not MISSING:
|
||||
base['name_localizations'] = {str(k): v for k, v in self.name_localizations.items()}
|
||||
return base
|
||||
|
||||
|
||||
class AppCommandChannel(Hashable):
|
||||
|
||||
@@ -46,10 +46,11 @@ from typing import (
|
||||
|
||||
from .errors import AppCommandError, TransformerError
|
||||
from .models import AppCommandChannel, AppCommandThread, Choice
|
||||
from .translator import locale_str, Translator, TranslationContext
|
||||
from ..channel import StageChannel, VoiceChannel, TextChannel, CategoryChannel
|
||||
from ..abc import GuildChannel
|
||||
from ..threads import Thread
|
||||
from ..enums import Enum as InternalEnum, AppCommandOptionType, ChannelType
|
||||
from ..enums import Enum as InternalEnum, AppCommandOptionType, ChannelType, Locale
|
||||
from ..utils import MISSING, maybe_coroutine
|
||||
from ..user import User
|
||||
from ..role import Role
|
||||
@@ -95,8 +96,10 @@ class CommandParameter:
|
||||
The maximum supported value for this parameter.
|
||||
"""
|
||||
|
||||
# The name of the parameter is *always* the parameter name in the code
|
||||
# Therefore, it can't be Union[str, locale_str]
|
||||
name: str = MISSING
|
||||
description: str = MISSING
|
||||
description: Union[str, locale_str] = MISSING
|
||||
required: bool = MISSING
|
||||
default: Any = MISSING
|
||||
choices: List[Choice[Union[str, int, float]]] = MISSING
|
||||
@@ -105,9 +108,49 @@ class CommandParameter:
|
||||
min_value: Optional[Union[int, float]] = None
|
||||
max_value: Optional[Union[int, float]] = None
|
||||
autocomplete: Optional[Callable[..., Coroutine[Any, Any, Any]]] = None
|
||||
_rename: str = MISSING
|
||||
_rename: Union[str, locale_str] = MISSING
|
||||
_annotation: Any = MISSING
|
||||
|
||||
async def get_translated_payload(self, translator: Translator) -> Dict[str, Any]:
|
||||
base = self.to_dict()
|
||||
|
||||
needs_name_translations = isinstance(self._rename, locale_str)
|
||||
needs_description_translations = isinstance(self.description, locale_str)
|
||||
name_localizations: Dict[str, str] = {}
|
||||
description_localizations: Dict[str, str] = {}
|
||||
for locale in Locale:
|
||||
if needs_name_translations:
|
||||
translation = await translator._checked_translate(
|
||||
self._rename, # type: ignore # This will always be locale_str
|
||||
locale,
|
||||
TranslationContext.parameter_name,
|
||||
)
|
||||
if translation is not None:
|
||||
name_localizations[locale.value] = translation
|
||||
|
||||
if needs_description_translations:
|
||||
translation = await translator._checked_translate(
|
||||
self.description, # type: ignore # This will always be locale_str
|
||||
locale,
|
||||
TranslationContext.parameter_description,
|
||||
)
|
||||
if translation is not None:
|
||||
description_localizations[locale.value] = translation
|
||||
|
||||
if isinstance(self.description, locale_str):
|
||||
base['description'] = self.description.message
|
||||
|
||||
if self.choices:
|
||||
base['choices'] = [await choice.get_translated_payload(translator) for choice in self.choices]
|
||||
|
||||
if name_localizations:
|
||||
base['name_localizations'] = name_localizations
|
||||
|
||||
if description_localizations:
|
||||
base['description_localizations'] = description_localizations
|
||||
|
||||
return base
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
base = {
|
||||
'type': self.type.value,
|
||||
@@ -158,7 +201,7 @@ class CommandParameter:
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
""":class:`str`: The name of the parameter as it should be displayed to the user."""
|
||||
return self.name if self._rename is MISSING else self._rename
|
||||
return self.name if self._rename is MISSING else str(self._rename)
|
||||
|
||||
|
||||
class Transformer:
|
||||
|
||||
195
discord/app_commands/translator.py
Normal file
195
discord/app_commands/translator.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015-present Rapptz
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of this software and associated documentation files (the "Software"),
|
||||
to deal in the Software without restriction, including without limitation
|
||||
the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
and/or sell copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import Any, Optional
|
||||
from .errors import TranslationError
|
||||
from ..enums import Enum, Locale
|
||||
|
||||
|
||||
__all__ = (
|
||||
'TranslationContext',
|
||||
'Translator',
|
||||
'locale_str',
|
||||
)
|
||||
|
||||
|
||||
class TranslationContext(Enum):
|
||||
command_name = 0
|
||||
command_description = 1
|
||||
parameter_name = 2
|
||||
parameter_description = 3
|
||||
choice_name = 4
|
||||
|
||||
|
||||
class Translator:
|
||||
"""A class that handles translations for commands, parameters, and choices.
|
||||
|
||||
Translations are done lazily in order to allow for async enabled translations as well
|
||||
as supporting a wide array of translation systems such as :mod:`gettext` and
|
||||
`Project Fluent <https://projectfluent.org>`_.
|
||||
|
||||
In order for a translator to be used, it must be set using the :meth:`CommandTree.set_translator`
|
||||
method. The translation flow for a string is as follows:
|
||||
|
||||
1. Use :class:`locale_str` instead of :class:`str` in areas of a command you want to be translated.
|
||||
- Currently, these are command names, command descriptions, parameter names, parameter descriptions, and choice names.
|
||||
- This can also be used inside the :func:`~discord.app_commands.describe` decorator.
|
||||
2. Call :meth:`CommandTree.set_translator` to the translator instance that will handle the translations.
|
||||
3. Call :meth:`CommandTree.sync`
|
||||
4. The library will call :meth:`Translator.translate` on all the relevant strings being translated.
|
||||
|
||||
.. versionadded:: 2.0
|
||||
"""
|
||||
|
||||
async def load(self) -> None:
|
||||
"""|coro|
|
||||
|
||||
An asynchronous setup function for loading the translation system.
|
||||
|
||||
The default implementation does nothing.
|
||||
|
||||
This is invoked when :meth:`CommandTree.set_translator` is called.
|
||||
"""
|
||||
pass
|
||||
|
||||
async def unload(self) -> None:
|
||||
"""|coro|
|
||||
|
||||
An asynchronous teardown function for unloading the translation system.
|
||||
|
||||
The default implementation does nothing.
|
||||
|
||||
This is invoked when :meth:`CommandTree.set_translator` is called
|
||||
if a tree already has a translator or when :meth:`discord.Client.close` is called.
|
||||
"""
|
||||
pass
|
||||
|
||||
async def _checked_translate(self, string: locale_str, locale: Locale, context: TranslationContext) -> Optional[str]:
|
||||
try:
|
||||
return await self.translate(string, locale, context)
|
||||
except TranslationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise TranslationError(string=string, locale=locale, context=context) from e
|
||||
|
||||
async def translate(self, string: locale_str, locale: Locale, context: TranslationContext) -> Optional[str]:
|
||||
"""|coro|
|
||||
|
||||
Translates the given string to the specified locale.
|
||||
|
||||
If the string cannot be translated, ``None`` should be returned.
|
||||
|
||||
The default implementation returns ``None``.
|
||||
|
||||
If an exception is raised in this method, it should inherit from :exc:`TranslationError`.
|
||||
If it doesn't, then when this is called the exception will be chained with it instead.
|
||||
|
||||
Parameters
|
||||
------------
|
||||
string: :class:`locale_str`
|
||||
The string being translated.
|
||||
locale: :class:`~discord.Locale`
|
||||
The locale being requested for translation.
|
||||
context: :class:`TranslationContext`
|
||||
The translation context where the string originated from.
|
||||
"""
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class locale_str:
|
||||
"""Marks a string as ready for translation.
|
||||
|
||||
This is done lazily and is not actually translated until :meth:`CommandTree.sync` is called.
|
||||
|
||||
The sync method then ultimately defers the responsibility of translating to the :class:`Translator`
|
||||
instance used by the :class:`CommandTree`. For more information on the translation flow, see the
|
||||
:class:`Translator` documentation.
|
||||
|
||||
.. container:: operations
|
||||
|
||||
.. describe:: str(x)
|
||||
|
||||
Returns the message passed to the string.
|
||||
|
||||
.. describe:: x == y
|
||||
|
||||
Checks if the string is equal to another string.
|
||||
|
||||
.. describe:: x != y
|
||||
|
||||
Checks if the string is not equal to another string.
|
||||
|
||||
.. describe:: hash(x)
|
||||
|
||||
Returns the hash of the string.
|
||||
|
||||
.. versionadded:: 2.0
|
||||
|
||||
Attributes
|
||||
------------
|
||||
message: :class:`str`
|
||||
The message being translated. Once set, this cannot be changed.
|
||||
|
||||
.. warning::
|
||||
|
||||
This must be the default "message" that you send to Discord.
|
||||
Discord sends this message back to the library and the library
|
||||
uses it to access the data in order to dispatch commands.
|
||||
|
||||
For example, in a command name context, if the command
|
||||
name is ``foo`` then the message *must* also be ``foo``.
|
||||
For other translation systems that require a message ID such
|
||||
as Fluent, consider using a keyword argument to pass it in.
|
||||
extras: :class:`dict`
|
||||
A dict of user provided extras to attach to the translated string.
|
||||
This can be used to add more context, information, or any metadata necessary
|
||||
to aid in actually translating the string.
|
||||
|
||||
Since these are passed via keyword arguments, the keys are strings.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, /, **kwargs: Any) -> None:
|
||||
self.__message: str = message
|
||||
self.extras: dict[str, Any] = kwargs
|
||||
|
||||
@property
|
||||
def message(self) -> str:
|
||||
return self.__message
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.__message
|
||||
|
||||
def __repr__(self) -> str:
|
||||
kwargs = ', '.join(f'{k}={v!r}' for k, v in self.extras.items())
|
||||
if kwargs:
|
||||
return f'{self.__class__.__name__}({self.__message!r}, {kwargs})'
|
||||
return f'{self.__class__.__name__}({self.__message!r})'
|
||||
|
||||
def __eq__(self, obj: object) -> bool:
|
||||
return isinstance(obj, locale_str) and self.message == obj.message
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.__message)
|
||||
@@ -58,6 +58,7 @@ from .errors import (
|
||||
CommandLimitReached,
|
||||
MissingApplicationID,
|
||||
)
|
||||
from .translator import Translator, locale_str
|
||||
from ..errors import ClientException
|
||||
from ..enums import AppCommandType, InteractionType
|
||||
from ..utils import MISSING, _get_as_snowflake, _is_submodule
|
||||
@@ -832,8 +833,8 @@ class CommandTree(Generic[ClientT]):
|
||||
def command(
|
||||
self,
|
||||
*,
|
||||
name: str = MISSING,
|
||||
description: str = MISSING,
|
||||
name: Union[str, locale_str] = MISSING,
|
||||
description: Union[str, locale_str] = MISSING,
|
||||
nsfw: bool = False,
|
||||
guild: Optional[Snowflake] = MISSING,
|
||||
guilds: Sequence[Snowflake] = MISSING,
|
||||
@@ -843,10 +844,10 @@ class CommandTree(Generic[ClientT]):
|
||||
|
||||
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.
|
||||
@@ -894,7 +895,7 @@ class CommandTree(Generic[ClientT]):
|
||||
def context_menu(
|
||||
self,
|
||||
*,
|
||||
name: str = MISSING,
|
||||
name: Union[str, locale_str] = MISSING,
|
||||
nsfw: bool = False,
|
||||
guild: Optional[Snowflake] = MISSING,
|
||||
guilds: Sequence[Snowflake] = MISSING,
|
||||
@@ -921,7 +922,7 @@ class CommandTree(Generic[ClientT]):
|
||||
|
||||
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.
|
||||
@@ -952,11 +953,54 @@ class CommandTree(Generic[ClientT]):
|
||||
|
||||
return decorator
|
||||
|
||||
@property
|
||||
def translator(self) -> Optional[Translator]:
|
||||
"""Optional[:class:`Translator`]: The translator, if any, responsible for handling translation of commands.
|
||||
|
||||
To change the translator, use :meth:`set_translator`.
|
||||
"""
|
||||
return self._state._translator
|
||||
|
||||
async def set_translator(self, translator: Optional[Translator]) -> None:
|
||||
"""Sets the translator to use for translating commands.
|
||||
|
||||
If a translator was previously set, it will be unloaded using its
|
||||
:meth:`Translator.unload` method.
|
||||
|
||||
When a translator is set, it will be loaded using its :meth:`Translator.load` method.
|
||||
|
||||
Parameters
|
||||
------------
|
||||
translator: Optional[:class:`Translator`]
|
||||
The translator to use. If ``None`` then the translator is just removed and unloaded.
|
||||
|
||||
Raises
|
||||
-------
|
||||
TypeError
|
||||
The translator was not ``None`` or a :class:`Translator` instance.
|
||||
"""
|
||||
|
||||
if translator is not None and not isinstance(translator, Translator):
|
||||
raise TypeError(f'expected None or Translator instance, received {translator.__class__!r} instead')
|
||||
|
||||
old_translator = self._state._translator
|
||||
if old_translator is not None:
|
||||
await old_translator.unload()
|
||||
|
||||
if translator is None:
|
||||
self._state._translator = None
|
||||
else:
|
||||
await translator.load()
|
||||
self._state._translator = translator
|
||||
|
||||
async def sync(self, *, guild: Optional[Snowflake] = None) -> List[AppCommand]:
|
||||
"""|coro|
|
||||
|
||||
Syncs the application commands to Discord.
|
||||
|
||||
This also runs the translator to get the translated strings necessary for
|
||||
feeding back into Discord.
|
||||
|
||||
This must be called for the application commands to show up.
|
||||
|
||||
Parameters
|
||||
@@ -973,6 +1017,8 @@ class CommandTree(Generic[ClientT]):
|
||||
The client does not have the ``applications.commands`` scope in the guild.
|
||||
MissingApplicationID
|
||||
The client does not have an application ID.
|
||||
TranslationError
|
||||
An error occurred while translating the commands.
|
||||
|
||||
Returns
|
||||
--------
|
||||
@@ -984,7 +1030,13 @@ class CommandTree(Generic[ClientT]):
|
||||
raise MissingApplicationID
|
||||
|
||||
commands = self._get_all_commands(guild=guild)
|
||||
payload = [command.to_dict() for command in commands]
|
||||
|
||||
translator = self.translator
|
||||
if translator:
|
||||
payload = [await command.get_translated_payload(translator) for command in commands]
|
||||
else:
|
||||
payload = [command.to_dict() for command in commands]
|
||||
|
||||
if guild is None:
|
||||
data = await self._http.bulk_upsert_global_commands(self.client.application_id, payload=payload)
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user