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 .tree import *
from .namespace import * from .namespace import *
from .transformers import * from .transformers import *
from .translator import *
from . import checks as checks from . import checks as checks
from .checks import Cooldown as Cooldown from .checks import Cooldown as Cooldown

183
discord/app_commands/commands.py

@ -48,10 +48,11 @@ from textwrap import TextWrapper
import re import re
from ..enums import AppCommandOptionType, AppCommandType from ..enums import AppCommandOptionType, AppCommandType, Locale
from .models import Choice from .models import Choice
from .transformers import annotation_to_parameter, CommandParameter, NoneType from .transformers import annotation_to_parameter, CommandParameter, NoneType
from .errors import AppCommandError, CheckFailure, CommandInvokeError, CommandSignatureMismatch, CommandAlreadyRegistered from .errors import AppCommandError, CheckFailure, CommandInvokeError, CommandSignatureMismatch, CommandAlreadyRegistered
from .translator import TranslationContext, Translator, locale_str
from ..message import Message from ..message import Message
from ..user import User from ..user import User
from ..member import Member from ..member import Member
@ -281,18 +282,21 @@ def _populate_descriptions(params: Dict[str, CommandParameter], descriptions: Di
param.description = '' param.description = ''
continue continue
if not isinstance(description, str): if not isinstance(description, (str, locale_str)):
raise TypeError('description must be a string') 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: if descriptions:
first = next(iter(descriptions)) first = next(iter(descriptions))
raise TypeError(f'unknown parameter given: {first}') raise TypeError(f'unknown parameter given: {first}')
def _populate_renames(params: Dict[str, CommandParameter], renames: Dict[str, str]) -> None: def _populate_renames(params: Dict[str, CommandParameter], renames: Dict[str, Union[str, locale_str]]) -> None:
rename_map: Dict[str, str] = {} rename_map: Dict[str, Union[str, locale_str]] = {}
# original name to renamed name # 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: if name in rename_map:
raise ValueError(f'{new_name} is already used') 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 rename_map[name] = new_name
params[name]._rename = 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) 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]): class Command(Generic[GroupT, P, T]):
"""A class that implements an application command. """A class that implements an application command.
@ -464,10 +478,12 @@ class Command(Generic[GroupT, P, T]):
Attributes Attributes
------------ ------------
name: :class:`str` 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` description: :class:`str`
The description of the application command. This shows up in the UI to describe 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 checks
A list of predicates that take a :class:`~discord.Interaction` parameter A list of predicates that take a :class:`~discord.Interaction` parameter
to indicate whether the command callback should be executed. If an exception to indicate whether the command callback should be executed. If an exception
@ -501,16 +517,22 @@ class Command(Generic[GroupT, P, T]):
def __init__( def __init__(
self, self,
*, *,
name: str, name: Union[str, locale_str],
description: str, description: Union[str, locale_str],
callback: CommandCallback[GroupT, P, T], callback: CommandCallback[GroupT, P, T],
nsfw: bool = False, nsfw: bool = False,
parent: Optional[Group] = None, parent: Optional[Group] = None,
guild_ids: Optional[List[int]] = None, guild_ids: Optional[List[int]] = None,
extras: Dict[Any, Any] = MISSING, 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.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.description: str = description
self._locale_description: Optional[locale_str] = locale
self._attr: Optional[str] = None self._attr: Optional[str] = None
self._callback: CommandCallback[GroupT, P, T] = callback self._callback: CommandCallback[GroupT, P, T] = callback
self.parent: Optional[Group] = parent self.parent: Optional[Group] = parent
@ -561,9 +583,11 @@ class Command(Generic[GroupT, P, T]):
cls = self.__class__ cls = self.__class__
copy = cls.__new__(cls) copy = cls.__new__(cls)
copy.name = self.name copy.name = self.name
copy._locale_name = self._locale_name
copy._guild_ids = self._guild_ids copy._guild_ids = self._guild_ids
copy.checks = self.checks copy.checks = self.checks
copy.description = self.description copy.description = self.description
copy._locale_description = self._locale_description
copy.default_permissions = self.default_permissions copy.default_permissions = self.default_permissions
copy.guild_only = self.guild_only copy.guild_only = self.guild_only
copy.nsfw = self.nsfw copy.nsfw = self.nsfw
@ -581,6 +605,28 @@ class Command(Generic[GroupT, P, T]):
return copy 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]: def to_dict(self) -> Dict[str, Any]:
# If we have a parent then our type is a subcommand # 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) # Otherwise, the type falls back to the specific command type (e.g. slash command or context menu)
@ -929,7 +975,8 @@ class ContextMenu:
Attributes Attributes
------------ ------------
name: :class:`str` 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` type: :class:`.AppCommandType`
The type of context menu application command. By default, this is inferred The type of context menu application command. By default, this is inferred
by the parameter of the callback. by the parameter of the callback.
@ -958,14 +1005,16 @@ class ContextMenu:
def __init__( def __init__(
self, self,
*, *,
name: str, name: Union[str, locale_str],
callback: ContextMenuCallback, callback: ContextMenuCallback,
type: AppCommandType = MISSING, type: AppCommandType = MISSING,
nsfw: bool = False, nsfw: bool = False,
guild_ids: Optional[List[int]] = None, guild_ids: Optional[List[int]] = None,
extras: Dict[Any, Any] = MISSING, 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.name: str = validate_context_menu_name(name)
self._locale_name: Optional[locale_str] = locale
self._callback: ContextMenuCallback = callback self._callback: ContextMenuCallback = callback
(param, annotation, actual_type) = _get_context_menu_parameter(callback) (param, annotation, actual_type) = _get_context_menu_parameter(callback)
if type is MISSING: if type is MISSING:
@ -998,6 +1047,18 @@ class ContextMenu:
""":class:`str`: Returns the fully qualified command name.""" """:class:`str`: Returns the fully qualified command name."""
return self.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]: def to_dict(self) -> Dict[str, Any]:
return { return {
'name': self.name, 'name': self.name,
@ -1107,11 +1168,13 @@ class Group:
------------ ------------
name: :class:`str` name: :class:`str`
The name of the group. If not given, it defaults to a lower-case 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` description: :class:`str`
The description of the group. This shows up in the UI to describe 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 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`] default_permissions: Optional[:class:`~discord.Permissions`]
The default permissions that can execute this group on Discord. Note The default permissions that can execute this group on Discord. Note
that server administrators can override this value in the client. 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_skip_init_binding__: bool = False
__discord_app_commands_group_name__: str = MISSING __discord_app_commands_group_name__: str = MISSING
__discord_app_commands_group_description__: 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_group_nsfw__: bool = False
__discord_app_commands_guild_only__: bool = MISSING __discord_app_commands_guild_only__: bool = MISSING
__discord_app_commands_default_permissions__: Optional[Permissions] = MISSING __discord_app_commands_default_permissions__: Optional[Permissions] = MISSING
@ -1151,8 +1216,8 @@ class Group:
def __init_subclass__( def __init_subclass__(
cls, cls,
*, *,
name: str = MISSING, name: Union[str, locale_str] = MISSING,
description: str = MISSING, description: Union[str, locale_str] = MISSING,
guild_only: bool = MISSING, guild_only: bool = MISSING,
nsfw: bool = False, nsfw: bool = False,
default_permissions: Optional[Permissions] = MISSING, default_permissions: Optional[Permissions] = MISSING,
@ -1175,16 +1240,22 @@ class Group:
if name is MISSING: if name is MISSING:
cls.__discord_app_commands_group_name__ = validate_name(_to_kebab_case(cls.__name__)) 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) 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 description is MISSING:
if cls.__doc__ is None: if cls.__doc__ is None:
cls.__discord_app_commands_group_description__ = '' cls.__discord_app_commands_group_description__ = ''
else: else:
cls.__discord_app_commands_group_description__ = _shorten(cls.__doc__) cls.__discord_app_commands_group_description__ = _shorten(cls.__doc__)
else: elif isinstance(description, str):
cls.__discord_app_commands_group_description__ = description 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: if guild_only is not MISSING:
cls.__discord_app_commands_guild_only__ = guild_only cls.__discord_app_commands_guild_only__ = guild_only
@ -1199,8 +1270,8 @@ class Group:
def __init__( def __init__(
self, self,
*, *,
name: str = MISSING, name: Union[str, locale_str] = MISSING,
description: str = MISSING, description: Union[str, locale_str] = MISSING,
parent: Optional[Group] = None, parent: Optional[Group] = None,
guild_ids: Optional[List[int]] = None, guild_ids: Optional[List[int]] = None,
guild_only: bool = MISSING, guild_only: bool = MISSING,
@ -1209,8 +1280,28 @@ class Group:
extras: Dict[Any, Any] = MISSING, extras: Dict[Any, Any] = MISSING,
): ):
cls = self.__class__ 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._attr: Optional[str] = None
self._owner_cls: Optional[Type[Any]] = 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) 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__ cls = self.__class__
copy = cls.__new__(cls) copy = cls.__new__(cls)
copy.name = self.name copy.name = self.name
copy._locale_name = self._locale_name
copy._guild_ids = self._guild_ids copy._guild_ids = self._guild_ids
copy.description = self.description copy.description = self.description
copy._locale_description = self._locale_description
copy.parent = parent copy.parent = parent
copy.module = self.module copy.module = self.module
copy.default_permissions = self.default_permissions copy.default_permissions = self.default_permissions
@ -1321,6 +1414,28 @@ class Group:
return copy 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]: def to_dict(self) -> Dict[str, Any]:
# If this has a parent command then it's part of a subcommand group # If this has a parent command then it's part of a subcommand group
# Otherwise, it's just a regular command # Otherwise, it's just a regular command
@ -1535,8 +1650,8 @@ class Group:
def command( def command(
self, self,
*, *,
name: str = MISSING, name: Union[str, locale_str] = MISSING,
description: str = MISSING, description: Union[str, locale_str] = MISSING,
nsfw: bool = False, nsfw: bool = False,
extras: Dict[Any, Any] = MISSING, extras: Dict[Any, Any] = MISSING,
) -> Callable[[CommandCallback[GroupT, P, T]], Command[GroupT, P, T]]: ) -> Callable[[CommandCallback[GroupT, P, T]], Command[GroupT, P, T]]:
@ -1544,10 +1659,10 @@ class Group:
Parameters 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 The name of the application command. If not given, it defaults to a lower-case
version of the callback name. 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 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 the application command. If not given, it defaults to the first line of the docstring
of the callback shortened to 100 characters. of the callback shortened to 100 characters.
@ -1586,8 +1701,8 @@ class Group:
def command( def command(
*, *,
name: str = MISSING, name: Union[str, locale_str] = MISSING,
description: str = MISSING, description: Union[str, locale_str] = MISSING,
nsfw: bool = False, nsfw: bool = False,
extras: Dict[Any, Any] = MISSING, extras: Dict[Any, Any] = MISSING,
) -> Callable[[CommandCallback[GroupT, P, T]], Command[GroupT, P, T]]: ) -> Callable[[CommandCallback[GroupT, P, T]], Command[GroupT, P, T]]:
@ -1637,7 +1752,7 @@ def command(
def context_menu( def context_menu(
*, *,
name: str = MISSING, name: Union[str, locale_str] = MISSING,
nsfw: bool = False, nsfw: bool = False,
extras: Dict[Any, Any] = MISSING, extras: Dict[Any, Any] = MISSING,
) -> Callable[[ContextMenuCallback], ContextMenu]: ) -> Callable[[ContextMenuCallback], ContextMenu]:
@ -1662,7 +1777,7 @@ def context_menu(
Parameters 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 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 version of the callback name. Note that unlike regular slash commands this can
have spaces and upper case characters in the name. have spaces and upper case characters in the name.
@ -1685,7 +1800,7 @@ def context_menu(
return decorator 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 r"""Describes the given parameters by their name using the key of the keyword argument
as the name. as the name.
@ -1700,7 +1815,7 @@ def describe(**parameters: str) -> Callable[[T], T]:
Parameters Parameters
----------- -----------
\*\*parameters \*\*parameters: Union[:class:`str`, :class:`locale_str`]
The description of the parameters. The description of the parameters.
Raises Raises
@ -1723,7 +1838,7 @@ def describe(**parameters: str) -> Callable[[T], T]:
return decorator 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 r"""Renames the given parameters by their name using the key of the keyword argument
as the name. as the name.
@ -1741,7 +1856,7 @@ def rename(**parameters: str) -> Callable[[T], T]:
Parameters Parameters
----------- -----------
\*\*parameters \*\*parameters: Union[:class:`str`, :class:`locale_str`]
The name of the parameters. The name of the parameters.
Raises 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 typing import Any, TYPE_CHECKING, List, Optional, Union
from ..enums import AppCommandOptionType, AppCommandType from ..enums import AppCommandOptionType, AppCommandType, Locale
from ..errors import DiscordException from ..errors import DiscordException
__all__ = ( __all__ = (
'AppCommandError', 'AppCommandError',
'CommandInvokeError', 'CommandInvokeError',
'TransformerError', 'TransformerError',
'TranslationError',
'CheckFailure', 'CheckFailure',
'CommandAlreadyRegistered', 'CommandAlreadyRegistered',
'CommandSignatureMismatch', 'CommandSignatureMismatch',
@ -51,6 +52,7 @@ __all__ = (
if TYPE_CHECKING: if TYPE_CHECKING:
from .commands import Command, Group, ContextMenu from .commands import Command, Group, ContextMenu
from .transformers import Transformer from .transformers import Transformer
from .translator import TranslationContext, locale_str
from ..types.snowflake import Snowflake, SnowflakeList from ..types.snowflake import Snowflake, SnowflakeList
from .checks import Cooldown from .checks import Cooldown
@ -133,6 +135,50 @@ class TransformerError(AppCommandError):
super().__init__(f'Failed to convert {value} to {transformer._error_display_name!s}') 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): class CheckFailure(AppCommandError):
"""An exception raised when check predicates in a command have failed. """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 datetime import datetime
from .errors import MissingApplicationID from .errors import MissingApplicationID
from .translator import Translator, TranslationContext, locale_str
from ..permissions import Permissions 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 ..mixins import Hashable
from ..utils import _get_as_snowflake, parse_time, snowflake_time, MISSING from ..utils import _get_as_snowflake, parse_time, snowflake_time, MISSING
from ..object import Object from ..object import Object
@ -56,7 +57,6 @@ def is_app_command_argument_type(value: int) -> bool:
if TYPE_CHECKING: if TYPE_CHECKING:
from ..types.command import ( from ..types.command import (
ApplicationCommand as ApplicationCommandPayload, ApplicationCommand as ApplicationCommandPayload,
ApplicationCommandOptionChoice,
ApplicationCommandOption, ApplicationCommandOption,
ApplicationCommandPermissions, ApplicationCommandPermissions,
GuildApplicationCommandPermissions, GuildApplicationCommandPermissions,
@ -407,17 +407,22 @@ class Choice(Generic[ChoiceT]):
Parameters Parameters
----------- -----------
name: :class:`str` name: Union[:class:`str`, :class:`locale_str`]
The name of the choice. Used for display purposes. 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`] value: Union[:class:`int`, :class:`str`, :class:`float`]
The value of the choice. 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.name: str = name
self._locale_name: Optional[locale_str] = locale
self.value: ChoiceT = value self.value: ChoiceT = value
self.name_localizations: Dict[Locale, str] = MISSING
def __eq__(self, o: object) -> bool: def __eq__(self, o: object) -> bool:
return isinstance(o, Choice) and self.name == o.name and self.value == o.value 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}' f'invalid Choice value type given, expected int, str, or float but received {self.value.__class__!r}'
) )
def to_dict(self) -> ApplicationCommandOptionChoice: async def get_translated_payload(self, translator: Translator) -> Dict[str, Any]:
return { # type: ignore 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, 'name': self.name,
'value': self.value, '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): class AppCommandChannel(Hashable):

51
discord/app_commands/transformers.py

@ -46,10 +46,11 @@ from typing import (
from .errors import AppCommandError, TransformerError from .errors import AppCommandError, TransformerError
from .models import AppCommandChannel, AppCommandThread, Choice from .models import AppCommandChannel, AppCommandThread, Choice
from .translator import locale_str, Translator, TranslationContext
from ..channel import StageChannel, VoiceChannel, TextChannel, CategoryChannel from ..channel import StageChannel, VoiceChannel, TextChannel, CategoryChannel
from ..abc import GuildChannel from ..abc import GuildChannel
from ..threads import Thread 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 ..utils import MISSING, maybe_coroutine
from ..user import User from ..user import User
from ..role import Role from ..role import Role
@ -95,8 +96,10 @@ class CommandParameter:
The maximum supported value for this parameter. 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 name: str = MISSING
description: str = MISSING description: Union[str, locale_str] = MISSING
required: bool = MISSING required: bool = MISSING
default: Any = MISSING default: Any = MISSING
choices: List[Choice[Union[str, int, float]]] = MISSING choices: List[Choice[Union[str, int, float]]] = MISSING
@ -105,9 +108,49 @@ class CommandParameter:
min_value: Optional[Union[int, float]] = None min_value: Optional[Union[int, float]] = None
max_value: Optional[Union[int, float]] = None max_value: Optional[Union[int, float]] = None
autocomplete: Optional[Callable[..., Coroutine[Any, Any, Any]]] = None autocomplete: Optional[Callable[..., Coroutine[Any, Any, Any]]] = None
_rename: str = MISSING _rename: Union[str, locale_str] = MISSING
_annotation: Any = 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]: def to_dict(self) -> Dict[str, Any]:
base = { base = {
'type': self.type.value, 'type': self.type.value,
@ -158,7 +201,7 @@ class CommandParameter:
@property @property
def display_name(self) -> str: def display_name(self) -> str:
""":class:`str`: The name of the parameter as it should be displayed to the user.""" """: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: 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, CommandLimitReached,
MissingApplicationID, MissingApplicationID,
) )
from .translator import Translator, locale_str
from ..errors import ClientException from ..errors import ClientException
from ..enums import AppCommandType, InteractionType from ..enums import AppCommandType, InteractionType
from ..utils import MISSING, _get_as_snowflake, _is_submodule from ..utils import MISSING, _get_as_snowflake, _is_submodule
@ -832,8 +833,8 @@ class CommandTree(Generic[ClientT]):
def command( def command(
self, self,
*, *,
name: str = MISSING, name: Union[str, locale_str] = MISSING,
description: str = MISSING, description: Union[str, locale_str] = MISSING,
nsfw: bool = False, nsfw: bool = False,
guild: Optional[Snowflake] = MISSING, guild: Optional[Snowflake] = MISSING,
guilds: Sequence[Snowflake] = MISSING, guilds: Sequence[Snowflake] = MISSING,
@ -843,10 +844,10 @@ class CommandTree(Generic[ClientT]):
Parameters 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 The name of the application command. If not given, it defaults to a lower-case
version of the callback name. 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 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 the application command. If not given, it defaults to the first line of the docstring
of the callback shortened to 100 characters. of the callback shortened to 100 characters.
@ -894,7 +895,7 @@ class CommandTree(Generic[ClientT]):
def context_menu( def context_menu(
self, self,
*, *,
name: str = MISSING, name: Union[str, locale_str] = MISSING,
nsfw: bool = False, nsfw: bool = False,
guild: Optional[Snowflake] = MISSING, guild: Optional[Snowflake] = MISSING,
guilds: Sequence[Snowflake] = MISSING, guilds: Sequence[Snowflake] = MISSING,
@ -921,7 +922,7 @@ class CommandTree(Generic[ClientT]):
Parameters 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 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 version of the callback name. Note that unlike regular slash commands this can
have spaces and upper case characters in the name. have spaces and upper case characters in the name.
@ -952,11 +953,54 @@ class CommandTree(Generic[ClientT]):
return decorator 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]: async def sync(self, *, guild: Optional[Snowflake] = None) -> List[AppCommand]:
"""|coro| """|coro|
Syncs the application commands to Discord. 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. This must be called for the application commands to show up.
Parameters Parameters
@ -973,6 +1017,8 @@ class CommandTree(Generic[ClientT]):
The client does not have the ``applications.commands`` scope in the guild. The client does not have the ``applications.commands`` scope in the guild.
MissingApplicationID MissingApplicationID
The client does not have an application ID. The client does not have an application ID.
TranslationError
An error occurred while translating the commands.
Returns Returns
-------- --------
@ -984,7 +1030,13 @@ class CommandTree(Generic[ClientT]):
raise MissingApplicationID raise MissingApplicationID
commands = self._get_all_commands(guild=guild) 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: if guild is None:
data = await self._http.bulk_upsert_global_commands(self.client.application_id, payload=payload) data = await self._http.bulk_upsert_global_commands(self.client.application_id, payload=payload)
else: else:

7
discord/client.py

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

4
discord/ext/commands/bot.py

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

12
discord/ext/commands/cog.py

@ -124,12 +124,12 @@ class CogMeta(type):
async def bar(self, ctx): async def bar(self, ctx):
pass # hidden -> False 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. The group name of a cog. This is only applicable for :class:`GroupCog` instances.
By default, it's the same value as :attr:`name`. By default, it's the same value as :attr:`name`.
.. versionadded:: 2.0 .. 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. The group description of a cog. This is only applicable for :class:`GroupCog` instances.
By default, it's the same value as :attr:`description`. By default, it's the same value as :attr:`description`.
@ -143,8 +143,8 @@ class CogMeta(type):
__cog_name__: str __cog_name__: str
__cog_description__: str __cog_description__: str
__cog_group_name__: str __cog_group_name__: Union[str, app_commands.locale_str]
__cog_group_description__: str __cog_group_description__: Union[str, app_commands.locale_str]
__cog_group_nsfw__: bool __cog_group_nsfw__: bool
__cog_settings__: Dict[str, Any] __cog_settings__: Dict[str, Any]
__cog_commands__: List[Command[Any, ..., Any]] __cog_commands__: List[Command[Any, ..., Any]]
@ -260,8 +260,8 @@ class Cog(metaclass=CogMeta):
__cog_name__: str __cog_name__: str
__cog_description__: str __cog_description__: str
__cog_group_name__: str __cog_group_name__: Union[str, app_commands.locale_str]
__cog_group_description__: str __cog_group_description__: Union[str, app_commands.locale_str]
__cog_settings__: Dict[str, Any] __cog_settings__: Dict[str, Any]
__cog_commands__: List[Command[Self, ..., Any]] __cog_commands__: List[Command[Self, ..., Any]]
__cog_app_commands__: List[Union[app_commands.Group, app_commands.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]): 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) signature = inspect.signature(wrapped.callback)
params = replace_parameters(wrapped.params, wrapped.callback, signature) params = replace_parameters(wrapped.params, wrapped.callback, signature)
wrapped.callback.__signature__ = signature.replace(parameters=params) wrapped.callback.__signature__ = signature.replace(parameters=params)
nsfw = getattr(wrapped.callback, '__discord_app_commands_is_nsfw__', False) nsfw = getattr(wrapped.callback, '__discord_app_commands_is_nsfw__', False)
try: try:
super().__init__( 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 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, nsfw=nsfw,
) )
finally: finally:
del wrapped.callback.__signature__ 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 self.binding: Optional[CogT] = wrapped.cog
# This technically means only one flag converter is supported # This technically means only one flag converter is supported
self.flag_converter: Optional[Tuple[str, Type[FlagConverter]]] = getattr( self.flag_converter: Optional[Tuple[str, Type[FlagConverter]]] = getattr(
@ -484,11 +484,25 @@ class HybridCommand(Command[CogT, P, T]):
self, self,
func: CommandCallback[CogT, Context[Any], P, T], 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, **kwargs: Any,
) -> None: ) -> 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) super().__init__(func, **kwargs)
self.with_app_command: bool = kwargs.pop('with_app_command', True) self.with_app_command: bool = kwargs.pop('with_app_command', True)
self.with_command: bool = kwargs.pop('with_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: if not self.with_command and not self.with_app_command:
raise TypeError('cannot set both with_command and with_app_command to False') 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 __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) super().__init__(*args, **attrs)
self.invoke_without_command = True self.invoke_without_command = True
self.with_app_command: bool = attrs.pop('with_app_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 parent = None
if self.parent is not 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) default_permissions = getattr(self.callback, '__discord_app_commands_default_permissions__', None)
nsfw = getattr(self.callback, '__discord_app_commands_is_nsfw__', False) nsfw = getattr(self.callback, '__discord_app_commands_is_nsfw__', False)
self.app_command = app_commands.Group( self.app_command = app_commands.Group(
name=self.name, name=self._locale_name or self.name,
description=self.description or self.short_doc or '', description=self._locale_description or self.description or self.short_doc or '',
guild_ids=guild_ids, guild_ids=guild_ids,
guild_only=guild_only, guild_only=guild_only,
default_permissions=default_permissions, default_permissions=default_permissions,
@ -762,7 +793,7 @@ class HybridGroup(Group[CogT, P, T]):
def command( def command(
self, self,
name: str = MISSING, name: Union[str, app_commands.locale_str] = MISSING,
*args: Any, *args: Any,
with_app_command: bool = True, with_app_command: bool = True,
**kwargs: Any, **kwargs: Any,
@ -786,7 +817,7 @@ class HybridGroup(Group[CogT, P, T]):
def group( def group(
self, self,
name: str = MISSING, name: Union[str, app_commands.locale_str] = MISSING,
*args: Any, *args: Any,
with_app_command: bool = True, with_app_command: bool = True,
**kwargs: Any, **kwargs: Any,
@ -810,7 +841,7 @@ class HybridGroup(Group[CogT, P, T]):
def hybrid_command( def hybrid_command(
name: str = MISSING, name: Union[str, app_commands.locale_str] = MISSING,
*, *,
with_app_command: bool = True, with_app_command: bool = True,
**attrs: Any, **attrs: Any,
@ -837,7 +868,7 @@ def hybrid_command(
Parameters 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 The name to create the command with. By default this uses the
function name unchanged. function name unchanged.
with_app_command: :class:`bool` with_app_command: :class:`bool`
@ -861,7 +892,7 @@ def hybrid_command(
def hybrid_group( def hybrid_group(
name: str = MISSING, name: Union[str, app_commands.locale_str] = MISSING,
*, *,
with_app_command: bool = True, with_app_command: bool = True,
**attrs: Any, **attrs: Any,

12
discord/interactions.py

@ -878,9 +878,15 @@ class InteractionResponse:
if self._response_type: if self._response_type:
raise InteractionResponded(self._parent) raise InteractionResponded(self._parent)
payload: Dict[str, Any] = { translator = self._parent._state._translator
'choices': [option.to_dict() for option in choices], 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 parent = self._parent
if parent.type is not InteractionType.autocomplete: if parent.type is not InteractionType.autocomplete:

7
discord/shard.py

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

16
discord/state.py

@ -83,7 +83,7 @@ if TYPE_CHECKING:
from .voice_client import VoiceProtocol from .voice_client import VoiceProtocol
from .client import Client from .client import Client
from .gateway import DiscordWebSocket from .gateway import DiscordWebSocket
from .app_commands import CommandTree from .app_commands import CommandTree, Translator
from .types.automod import AutoModerationRule, AutoModerationActionExecution from .types.automod import AutoModerationRule, AutoModerationActionExecution
from .types.snowflake import Snowflake from .types.snowflake import Snowflake
@ -245,6 +245,7 @@ class ConnectionState:
self._status: Optional[str] = status self._status: Optional[str] = status
self._intents: Intents = intents self._intents: Intents = intents
self._command_tree: Optional[CommandTree] = None self._command_tree: Optional[CommandTree] = None
self._translator: Optional[Translator] = None
if not intents.members or cache_flags._empty: if not intents.members or cache_flags._empty:
self.store_user = self.store_user_no_intents self.store_user = self.store_user_no_intents
@ -257,6 +258,19 @@ class ConnectionState:
self.clear() 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: def clear(self, *, views: bool = True) -> None:
self.user: Optional[ClientUser] = None self.user: Optional[ClientUser] = None
self._users: weakref.WeakValueDictionary[int, User] = weakref.WeakValueDictionary() 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 __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 typing_extensions import NotRequired, Required
from .channel import ChannelType from .channel import ChannelType
@ -37,6 +37,8 @@ ApplicationCommandOptionType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
class _BaseApplicationCommandOption(TypedDict): class _BaseApplicationCommandOption(TypedDict):
name: str name: str
description: str description: str
name_localizations: NotRequired[Optional[Dict[str, str]]]
description_localizations: NotRequired[Optional[Dict[str, str]]]
class _SubCommandCommandOption(_BaseApplicationCommandOption): class _SubCommandCommandOption(_BaseApplicationCommandOption):
@ -55,6 +57,7 @@ class _BaseValueApplicationCommandOption(_BaseApplicationCommandOption, total=Fa
class _StringApplicationCommandOptionChoice(TypedDict): class _StringApplicationCommandOptionChoice(TypedDict):
name: str name: str
name_localizations: NotRequired[Optional[Dict[str, str]]]
value: str value: str
@ -68,6 +71,7 @@ class _StringApplicationCommandOption(_BaseApplicationCommandOption):
class _IntegerApplicationCommandOptionChoice(TypedDict): class _IntegerApplicationCommandOptionChoice(TypedDict):
name: str name: str
name_localizations: NotRequired[Optional[Dict[str, str]]]
value: int value: int
@ -100,6 +104,7 @@ _SnowflakeApplicationCommandOptionChoice = Union[
class _NumberApplicationCommandOptionChoice(TypedDict): class _NumberApplicationCommandOptionChoice(TypedDict):
name: str name: str
name_localizations: NotRequired[Optional[Dict[str, str]]]
value: float value: float
@ -140,6 +145,8 @@ class _BaseApplicationCommand(TypedDict):
default_member_permissions: NotRequired[Optional[str]] default_member_permissions: NotRequired[Optional[str]]
nsfw: NotRequired[bool] nsfw: NotRequired[bool]
version: Snowflake version: Snowflake
name_localizations: NotRequired[Optional[Dict[str, str]]]
description_localizations: NotRequired[Optional[Dict[str, str]]]
class _ChatInputApplicationCommand(_BaseApplicationCommand, total=False): class _ChatInputApplicationCommand(_BaseApplicationCommand, total=False):

52
docs/interactions/api.rst

@ -377,7 +377,7 @@ Enumerations
A message context menu command. A message context menu command.
.. class:: AppCommandPermissionType .. class:: AppCommandPermissionType
The application command's permission type. The application command's permission type.
.. versionadded:: 2.0 .. versionadded:: 2.0
@ -596,6 +596,52 @@ Range
.. autoclass:: discord.app_commands.Range .. autoclass:: discord.app_commands.Range
:members: :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 Exceptions
~~~~~~~~~~~ ~~~~~~~~~~~
@ -608,6 +654,9 @@ Exceptions
.. autoexception:: discord.app_commands.TransformerError .. autoexception:: discord.app_commands.TransformerError
:members: :members:
.. autoexception:: discord.app_commands.TranslationError
:members:
.. autoexception:: discord.app_commands.CheckFailure .. autoexception:: discord.app_commands.CheckFailure
:members: :members:
@ -653,6 +702,7 @@ Exception Hierarchy
- :exc:`~discord.app_commands.AppCommandError` - :exc:`~discord.app_commands.AppCommandError`
- :exc:`~discord.app_commands.CommandInvokeError` - :exc:`~discord.app_commands.CommandInvokeError`
- :exc:`~discord.app_commands.TransformerError` - :exc:`~discord.app_commands.TransformerError`
- :exc:`~discord.app_commands.TranslationError`
- :exc:`~discord.app_commands.CheckFailure` - :exc:`~discord.app_commands.CheckFailure`
- :exc:`~discord.app_commands.NoPrivateMessage` - :exc:`~discord.app_commands.NoPrivateMessage`
- :exc:`~discord.app_commands.MissingRole` - :exc:`~discord.app_commands.MissingRole`

Loading…
Cancel
Save