Browse Source

Add initial support for app command localisation

pull/8314/head
Rapptz 3 years ago
parent
commit
2d586ae805
  1. 1
      discord/app_commands/__init__.py
  2. 183
      discord/app_commands/commands.py
  3. 48
      discord/app_commands/errors.py
  4. 36
      discord/app_commands/models.py
  5. 51
      discord/app_commands/transformers.py
  6. 195
      discord/app_commands/translator.py
  7. 66
      discord/app_commands/tree.py
  8. 7
      discord/client.py
  9. 4
      discord/ext/commands/bot.py
  10. 12
      discord/ext/commands/cog.py
  11. 55
      discord/ext/commands/hybrid.py
  12. 12
      discord/interactions.py
  13. 7
      discord/shard.py
  14. 16
      discord/state.py
  15. 9
      discord/types/command.py
  16. 52
      docs/interactions/api.rst

1
discord/app_commands/__init__.py

@ -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

183
discord/app_commands/commands.py

@ -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

48
discord/app_commands/errors.py

@ -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.

36
discord/app_commands/models.py

@ -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):

51
discord/app_commands/transformers.py

@ -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

@ -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)

66
discord/app_commands/tree.py

@ -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:

7
discord/client.py

@ -737,12 +737,7 @@ class Client:
self._closed = True
for voice in self.voice_clients:
try:
await voice.disconnect(force=True)
except Exception:
# if an error happens during disconnects, disregard it.
pass
await self._connection.close()
if self.ws is not None and self.ws.open:
await self.ws.close(code=1000)

4
discord/ext/commands/bot.py

@ -253,7 +253,7 @@ class BotBase(GroupMixin[None]):
def hybrid_command(
self,
name: str = MISSING,
name: Union[str, app_commands.locale_str] = MISSING,
with_app_command: bool = True,
*args: Any,
**kwargs: Any,
@ -277,7 +277,7 @@ class BotBase(GroupMixin[None]):
def hybrid_group(
self,
name: str = MISSING,
name: Union[str, app_commands.locale_str] = MISSING,
with_app_command: bool = True,
*args: Any,
**kwargs: Any,

12
discord/ext/commands/cog.py

@ -124,12 +124,12 @@ class CogMeta(type):
async def bar(self, ctx):
pass # hidden -> False
group_name: :class:`str`
group_name: Union[:class:`str`, :class:`~discord.app_commands.locale_str`]
The group name of a cog. This is only applicable for :class:`GroupCog` instances.
By default, it's the same value as :attr:`name`.
.. versionadded:: 2.0
group_description: :class:`str`
group_description: Union[:class:`str`, :class:`~discord.app_commands.locale_str`]
The group description of a cog. This is only applicable for :class:`GroupCog` instances.
By default, it's the same value as :attr:`description`.
@ -143,8 +143,8 @@ class CogMeta(type):
__cog_name__: str
__cog_description__: str
__cog_group_name__: str
__cog_group_description__: str
__cog_group_name__: Union[str, app_commands.locale_str]
__cog_group_description__: Union[str, app_commands.locale_str]
__cog_group_nsfw__: bool
__cog_settings__: Dict[str, Any]
__cog_commands__: List[Command[Any, ..., Any]]
@ -260,8 +260,8 @@ class Cog(metaclass=CogMeta):
__cog_name__: str
__cog_description__: str
__cog_group_name__: str
__cog_group_description__: str
__cog_group_name__: Union[str, app_commands.locale_str]
__cog_group_description__: Union[str, app_commands.locale_str]
__cog_settings__: Dict[str, Any]
__cog_commands__: List[Command[Self, ..., Any]]
__cog_app_commands__: List[Union[app_commands.Group, app_commands.Command[Self, ..., Any]]]

55
discord/ext/commands/hybrid.py

@ -297,22 +297,22 @@ def replace_parameters(
class HybridAppCommand(discord.app_commands.Command[CogT, P, T]):
def __init__(self, wrapped: Command[CogT, Any, T]) -> None:
def __init__(self, wrapped: Union[HybridCommand[CogT, Any, T], HybridGroup[CogT, Any, T]]) -> None:
signature = inspect.signature(wrapped.callback)
params = replace_parameters(wrapped.params, wrapped.callback, signature)
wrapped.callback.__signature__ = signature.replace(parameters=params)
nsfw = getattr(wrapped.callback, '__discord_app_commands_is_nsfw__', False)
try:
super().__init__(
name=wrapped.name,
name=wrapped._locale_name or wrapped.name,
callback=wrapped.callback, # type: ignore # Signature doesn't match but we're overriding the invoke
description=wrapped.description or wrapped.short_doc or '',
description=wrapped._locale_description or wrapped.description or wrapped.short_doc or '',
nsfw=nsfw,
)
finally:
del wrapped.callback.__signature__
self.wrapped: Command[CogT, Any, T] = wrapped
self.wrapped: Union[HybridCommand[CogT, Any, T], HybridGroup[CogT, Any, T]] = wrapped
self.binding: Optional[CogT] = wrapped.cog
# This technically means only one flag converter is supported
self.flag_converter: Optional[Tuple[str, Type[FlagConverter]]] = getattr(
@ -484,11 +484,25 @@ class HybridCommand(Command[CogT, P, T]):
self,
func: CommandCallback[CogT, Context[Any], P, T],
/,
*,
name: Union[str, app_commands.locale_str] = MISSING,
description: Union[str, app_commands.locale_str] = MISSING,
**kwargs: Any,
) -> None:
name, name_locale = (name.message, name) if isinstance(name, app_commands.locale_str) else (name, None)
if name is not MISSING:
kwargs['name'] = name
description, description_locale = (
(description.message, description) if isinstance(description, app_commands.locale_str) else (description, None)
)
if description is not MISSING:
kwargs['description'] = description
super().__init__(func, **kwargs)
self.with_app_command: bool = kwargs.pop('with_app_command', True)
self.with_command: bool = kwargs.pop('with_command', True)
self._locale_name: Optional[app_commands.locale_str] = name_locale
self._locale_description: Optional[app_commands.locale_str] = description_locale
if not self.with_command and not self.with_app_command:
raise TypeError('cannot set both with_command and with_app_command to False')
@ -586,10 +600,27 @@ class HybridGroup(Group[CogT, P, T]):
__commands_is_hybrid__: ClassVar[bool] = True
def __init__(self, *args: Any, fallback: Optional[str] = None, **attrs: Any) -> None:
def __init__(
self,
*args: Any,
name: Union[str, app_commands.locale_str] = MISSING,
description: Union[str, app_commands.locale_str] = MISSING,
fallback: Optional[str] = None,
**attrs: Any,
) -> None:
name, name_locale = (name.message, name) if isinstance(name, app_commands.locale_str) else (name, None)
if name is not MISSING:
attrs['name'] = name
description, description_locale = (
(description.message, description) if isinstance(description, app_commands.locale_str) else (description, None)
)
if description is not MISSING:
attrs['description'] = description
super().__init__(*args, **attrs)
self.invoke_without_command = True
self.with_app_command: bool = attrs.pop('with_app_command', True)
self._locale_name: Optional[app_commands.locale_str] = name_locale
self._locale_description: Optional[app_commands.locale_str] = description_locale
parent = None
if self.parent is not None:
@ -612,8 +643,8 @@ class HybridGroup(Group[CogT, P, T]):
default_permissions = getattr(self.callback, '__discord_app_commands_default_permissions__', None)
nsfw = getattr(self.callback, '__discord_app_commands_is_nsfw__', False)
self.app_command = app_commands.Group(
name=self.name,
description=self.description or self.short_doc or '',
name=self._locale_name or self.name,
description=self._locale_description or self.description or self.short_doc or '',
guild_ids=guild_ids,
guild_only=guild_only,
default_permissions=default_permissions,
@ -762,7 +793,7 @@ class HybridGroup(Group[CogT, P, T]):
def command(
self,
name: str = MISSING,
name: Union[str, app_commands.locale_str] = MISSING,
*args: Any,
with_app_command: bool = True,
**kwargs: Any,
@ -786,7 +817,7 @@ class HybridGroup(Group[CogT, P, T]):
def group(
self,
name: str = MISSING,
name: Union[str, app_commands.locale_str] = MISSING,
*args: Any,
with_app_command: bool = True,
**kwargs: Any,
@ -810,7 +841,7 @@ class HybridGroup(Group[CogT, P, T]):
def hybrid_command(
name: str = MISSING,
name: Union[str, app_commands.locale_str] = MISSING,
*,
with_app_command: bool = True,
**attrs: Any,
@ -837,7 +868,7 @@ def hybrid_command(
Parameters
-----------
name: :class:`str`
name: Union[:class:`str`, :class:`~discord.app_commands.locale_str`]
The name to create the command with. By default this uses the
function name unchanged.
with_app_command: :class:`bool`
@ -861,7 +892,7 @@ def hybrid_command(
def hybrid_group(
name: str = MISSING,
name: Union[str, app_commands.locale_str] = MISSING,
*,
with_app_command: bool = True,
**attrs: Any,

12
discord/interactions.py

@ -878,9 +878,15 @@ class InteractionResponse:
if self._response_type:
raise InteractionResponded(self._parent)
payload: Dict[str, Any] = {
'choices': [option.to_dict() for option in choices],
}
translator = self._parent._state._translator
if translator is not None:
payload: Dict[str, Any] = {
'choices': [await option.get_translated_payload(translator) for option in choices],
}
else:
payload: Dict[str, Any] = {
'choices': [option.to_dict() for option in choices],
}
parent = self._parent
if parent.type is not InteractionType.autocomplete:

7
discord/shard.py

@ -471,12 +471,7 @@ class AutoShardedClient(Client):
return
self._closed = True
for vc in self.voice_clients:
try:
await vc.disconnect(force=True)
except Exception:
pass
await self._connection.close()
to_close = [asyncio.ensure_future(shard.close(), loop=self.loop) for shard in self.__shards.values()]
if to_close:

16
discord/state.py

@ -83,7 +83,7 @@ if TYPE_CHECKING:
from .voice_client import VoiceProtocol
from .client import Client
from .gateway import DiscordWebSocket
from .app_commands import CommandTree
from .app_commands import CommandTree, Translator
from .types.automod import AutoModerationRule, AutoModerationActionExecution
from .types.snowflake import Snowflake
@ -245,6 +245,7 @@ class ConnectionState:
self._status: Optional[str] = status
self._intents: Intents = intents
self._command_tree: Optional[CommandTree] = None
self._translator: Optional[Translator] = None
if not intents.members or cache_flags._empty:
self.store_user = self.store_user_no_intents
@ -257,6 +258,19 @@ class ConnectionState:
self.clear()
async def close(self) -> None:
for voice in self.voice_clients:
try:
await voice.disconnect(force=True)
except Exception:
# if an error happens during disconnects, disregard it.
pass
if self._translator:
await self._translator.unload()
# Purposefully don't call `clear` because users rely on cache being available post-close
def clear(self, *, views: bool = True) -> None:
self.user: Optional[ClientUser] = None
self._users: weakref.WeakValueDictionary[int, User] = weakref.WeakValueDictionary()

9
discord/types/command.py

@ -24,7 +24,7 @@ DEALINGS IN THE SOFTWARE.
from __future__ import annotations
from typing import List, Literal, Optional, TypedDict, Union
from typing import Dict, List, Literal, Optional, TypedDict, Union
from typing_extensions import NotRequired, Required
from .channel import ChannelType
@ -37,6 +37,8 @@ ApplicationCommandOptionType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
class _BaseApplicationCommandOption(TypedDict):
name: str
description: str
name_localizations: NotRequired[Optional[Dict[str, str]]]
description_localizations: NotRequired[Optional[Dict[str, str]]]
class _SubCommandCommandOption(_BaseApplicationCommandOption):
@ -55,6 +57,7 @@ class _BaseValueApplicationCommandOption(_BaseApplicationCommandOption, total=Fa
class _StringApplicationCommandOptionChoice(TypedDict):
name: str
name_localizations: NotRequired[Optional[Dict[str, str]]]
value: str
@ -68,6 +71,7 @@ class _StringApplicationCommandOption(_BaseApplicationCommandOption):
class _IntegerApplicationCommandOptionChoice(TypedDict):
name: str
name_localizations: NotRequired[Optional[Dict[str, str]]]
value: int
@ -100,6 +104,7 @@ _SnowflakeApplicationCommandOptionChoice = Union[
class _NumberApplicationCommandOptionChoice(TypedDict):
name: str
name_localizations: NotRequired[Optional[Dict[str, str]]]
value: float
@ -140,6 +145,8 @@ class _BaseApplicationCommand(TypedDict):
default_member_permissions: NotRequired[Optional[str]]
nsfw: NotRequired[bool]
version: Snowflake
name_localizations: NotRequired[Optional[Dict[str, str]]]
description_localizations: NotRequired[Optional[Dict[str, str]]]
class _ChatInputApplicationCommand(_BaseApplicationCommand, total=False):

52
docs/interactions/api.rst

@ -377,7 +377,7 @@ Enumerations
A message context menu command.
.. class:: AppCommandPermissionType
The application command's permission type.
.. versionadded:: 2.0
@ -596,6 +596,52 @@ Range
.. autoclass:: discord.app_commands.Range
:members:
Translations
~~~~~~~~~~~~~
Translator
+++++++++++
.. attributetable:: discord.app_commands.Translator
.. autoclass:: discord.app_commands.Translator
:members:
locale_str
+++++++++++
.. attributetable:: discord.app_commands.locale_str
.. autoclass:: discord.app_commands.locale_str
:members:
TranslationContext
+++++++++++++++++++
.. class:: TranslationContext
:module: discord.app_commands
An enum representing the context that the translation occurs in when requested for translation.
.. versionadded:: 2.0
.. attribute:: command_name
The translation involved a command name.
.. attribute:: command_description
The translation involved a command description.
.. attribute:: parameter_name
The translation involved a parameter name.
.. attribute:: parameter_description
The translation involved a parameter description.
.. attribute:: choice_name
The translation involved a choice name.
Exceptions
~~~~~~~~~~~
@ -608,6 +654,9 @@ Exceptions
.. autoexception:: discord.app_commands.TransformerError
:members:
.. autoexception:: discord.app_commands.TranslationError
:members:
.. autoexception:: discord.app_commands.CheckFailure
:members:
@ -653,6 +702,7 @@ Exception Hierarchy
- :exc:`~discord.app_commands.AppCommandError`
- :exc:`~discord.app_commands.CommandInvokeError`
- :exc:`~discord.app_commands.TransformerError`
- :exc:`~discord.app_commands.TranslationError`
- :exc:`~discord.app_commands.CheckFailure`
- :exc:`~discord.app_commands.NoPrivateMessage`
- :exc:`~discord.app_commands.MissingRole`

Loading…
Cancel
Save