From 2d586ae805f2ee035482a04fe7c7a64e54471169 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Tue, 2 Aug 2022 10:05:32 -0400 Subject: [PATCH] Add initial support for app command localisation --- discord/app_commands/__init__.py | 1 + discord/app_commands/commands.py | 183 ++++++++++++++++++++----- discord/app_commands/errors.py | 48 ++++++- discord/app_commands/models.py | 36 ++++- discord/app_commands/transformers.py | 51 ++++++- discord/app_commands/translator.py | 195 +++++++++++++++++++++++++++ discord/app_commands/tree.py | 66 ++++++++- discord/client.py | 7 +- discord/ext/commands/bot.py | 4 +- discord/ext/commands/cog.py | 12 +- discord/ext/commands/hybrid.py | 55 ++++++-- discord/interactions.py | 12 +- discord/shard.py | 7 +- discord/state.py | 16 ++- discord/types/command.py | 9 +- docs/interactions/api.rst | 52 ++++++- 16 files changed, 663 insertions(+), 91 deletions(-) create mode 100644 discord/app_commands/translator.py diff --git a/discord/app_commands/__init__.py b/discord/app_commands/__init__.py index 4a2909862..971461713 100644 --- a/discord/app_commands/__init__.py +++ b/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 diff --git a/discord/app_commands/commands.py b/discord/app_commands/commands.py index cc0af720d..12602ead5 100644 --- a/discord/app_commands/commands.py +++ b/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 diff --git a/discord/app_commands/errors.py b/discord/app_commands/errors.py index cad8052fa..b050d03e7 100644 --- a/discord/app_commands/errors.py +++ b/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. diff --git a/discord/app_commands/models.py b/discord/app_commands/models.py index 9fe66c45a..7b5a30143 100644 --- a/discord/app_commands/models.py +++ b/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): diff --git a/discord/app_commands/transformers.py b/discord/app_commands/transformers.py index 7d5157284..2e9f0ca25 100644 --- a/discord/app_commands/transformers.py +++ b/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: diff --git a/discord/app_commands/translator.py b/discord/app_commands/translator.py new file mode 100644 index 000000000..34270c66c --- /dev/null +++ b/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 `_. + + 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) diff --git a/discord/app_commands/tree.py b/discord/app_commands/tree.py index e24885d67..2a5b5ae33 100644 --- a/discord/app_commands/tree.py +++ b/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: diff --git a/discord/client.py b/discord/client.py index 226e6e3ff..d14148477 100644 --- a/discord/client.py +++ b/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) diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index dcc03944c..eb1cdee02 100644 --- a/discord/ext/commands/bot.py +++ b/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, diff --git a/discord/ext/commands/cog.py b/discord/ext/commands/cog.py index 0b70c66f5..5d7d1be34 100644 --- a/discord/ext/commands/cog.py +++ b/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]]] diff --git a/discord/ext/commands/hybrid.py b/discord/ext/commands/hybrid.py index 10aa928dd..f703bdc75 100644 --- a/discord/ext/commands/hybrid.py +++ b/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, diff --git a/discord/interactions.py b/discord/interactions.py index 490ae6c1e..328172d88 100644 --- a/discord/interactions.py +++ b/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: diff --git a/discord/shard.py b/discord/shard.py index 4dd021e93..bd431c5f8 100644 --- a/discord/shard.py +++ b/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: diff --git a/discord/state.py b/discord/state.py index 81390e701..032adb4f9 100644 --- a/discord/state.py +++ b/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() diff --git a/discord/types/command.py b/discord/types/command.py index 7968e6f5f..f4eb41ef8 100644 --- a/discord/types/command.py +++ b/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): diff --git a/docs/interactions/api.rst b/docs/interactions/api.rst index cb2b2bbcb..5b2f2c40b 100644 --- a/docs/interactions/api.rst +++ b/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`