From 96be56c0cf6d16b417441e82b0658a649e4985cb Mon Sep 17 00:00:00 2001 From: Timo <35654063+tibue99@users.noreply.github.com> Date: Tue, 2 Jan 2024 12:31:28 +0100 Subject: [PATCH 1/7] Implement dynamic languages --- ezcord/bot.py | 34 +++++++++--------- ezcord/cogs/blacklist.py | 2 +- ezcord/cogs/help.py | 24 ++++++++----- ezcord/internal/config.py | 1 + ezcord/internal/language/languages.py | 3 +- ezcord/internal/translation.py | 51 +++++++++++++-------------- ezcord/times.py | 31 +++++++++++----- ezcord/utils.py | 18 ++++++++-- 8 files changed, 99 insertions(+), 65 deletions(-) diff --git a/ezcord/bot.py b/ezcord/bot.py index bbf946d..7a6bd48 100644 --- a/ezcord/bot.py +++ b/ezcord/bot.py @@ -19,10 +19,8 @@ READY_TITLE, EzConfig, get_error_text, - load_lang, print_custom_ready, print_ready, - set_lang, t, ) from .internal.config import Blacklist @@ -76,10 +74,12 @@ class Bot(_main_bot): # type: ignore Whether to send the full error traceback. If this is ``False``, only the most recent traceback will be sent. Defaults to ``False``. language: - The language to use for user output. - - The default languages are If you add your own language file as - described in :doc:`the language example `, you can use that language as well. + The language to use for user output. If this is set to ``"auto"``, + the bot will try to use the language of the interaction locale. + default_language: + The default language to use if the interaction locale is not available. + Defaults to ``"en"``. ``en`` and ``de`` are available by default, but you can add your own + language as described in :doc:`the language example `. ready_event: The style for :meth:`on_ready_event`. Defaults to :attr:`.ReadyEvent.default`. If this is ``None``, the event will be disabled. @@ -96,7 +96,8 @@ def __init__( error_webhook_url: str | None = None, ignored_errors: list[Any] | None = None, full_error_traceback: bool = False, - language: str = "en", + language: str = "auto", + default_language: str = "en", ready_event: ReadyEvent | None = ReadyEvent.default, **kwargs, ): @@ -119,8 +120,9 @@ def __init__( self.error_webhook_url = error_webhook_url self.ignored_errors = ignored_errors or [] self.full_error_traceback = full_error_traceback - load_lang(language) - set_lang(language) if language != {} else set_lang("en") + + EzConfig.lang = language + EzConfig.default_lang = default_language self.error_event_added = False if error_handler or error_webhook_url: @@ -433,20 +435,20 @@ async def _error_event(self, ctx, error: discord.DiscordException): or type(error) is commands.CheckFailure ): if self.error_handler: - await error_emb(ctx, t("no_user_perms")) + await error_emb(ctx, t("no_user_perms", i=ctx)) return if isinstance(error, commands.CommandOnCooldown): if self.error_handler: seconds = round(ctx.command.get_cooldown_retry_after(ctx)) - cooldown_txt = t("cooldown", dc_timestamp(seconds)) - await error_emb(ctx, cooldown_txt, title=t("cooldown_title")) + cooldown_txt = t("cooldown", dc_timestamp(seconds), i=ctx) + await error_emb(ctx, cooldown_txt, title=t("cooldown_title", i=ctx)) elif isinstance(error, checks.BotMissingPermissions): if self.error_handler: perms = "\n".join(error.missing_permissions) - perm_txt = f"{t('no_perms')} ```\n{perms}```" - await error_emb(ctx, perm_txt, title=t("no_perms_title")) + perm_txt = f"{t('no_perms', i=ctx)} ```\n{perms}```" + await error_emb(ctx, perm_txt, title=t("no_perms_title", i=ctx)) else: if "original" in error.__dict__ and not self.full_error_traceback: @@ -457,9 +459,9 @@ async def _error_event(self, ctx, error: discord.DiscordException): error_msg = f"{error}" if self.error_handler: - error_txt = f"{t('error', f'```{error_msg}```')}" + error_txt = f"{t('error', f'```{error_msg}```', i=ctx)}" try: - await error_emb(ctx, error_txt, title=t("error_title")) + await error_emb(ctx, error_txt, title=t("error_title", i=ctx)) except discord.HTTPException as e: # ignore invalid interaction error, probably took too long to respond if e.code != 10062: diff --git a/ezcord/cogs/blacklist.py b/ezcord/cogs/blacklist.py index 46a4a34..6e6bebc 100644 --- a/ezcord/cogs/blacklist.py +++ b/ezcord/cogs/blacklist.py @@ -36,7 +36,7 @@ async def _check_blacklist(interaction: discord.Interaction) -> bool: if EzConfig.blacklist.raise_error: raise Blacklisted() else: - await interaction.response.send_message(t("no_perms"), ephemeral=True) + await interaction.response.send_message(t("no_perms", i=interaction), ephemeral=True) raise ErrorMessageSent() return True diff --git a/ezcord/cogs/help.py b/ezcord/cogs/help.py index 72b8829..de85140 100644 --- a/ezcord/cogs/help.py +++ b/ezcord/cogs/help.py @@ -82,7 +82,9 @@ def __init__(self, bot: Bot): async def help(self, ctx): embed = self.bot.help.embed if embed is None: - embed = discord.Embed(title=t("embed_title"), color=discord.Color.blue()) + embed = discord.Embed( + title=t("embed_title", i=ctx.interaction), color=discord.Color.blue() + ) else: interaction = ctx.interaction if PYCORD else ctx embed = replace_embed_values(embed, interaction) @@ -120,7 +122,7 @@ async def help(self, ctx): desc = cog.description if not cog.description: - desc = t("default_description", name) + desc = t("default_description", name, i=ctx) if not desc: log.warning( f"The default description for cog '{name}' is invalid. " @@ -184,7 +186,7 @@ async def help(self, ctx): embed.add_field(name=field_name, value=desc, inline=False) if len(options) == 0: - return await ctx.response.send_message(t("no_commands"), ephemeral=True) + return await ctx.response.send_message(t("no_commands", i=ctx), ephemeral=True) if len(options) > 25 or len(embed.fields) > 25: log.error( f"Help command category limit reached. Only 25 out of {len(options)} are shown." @@ -194,7 +196,7 @@ async def help(self, ctx): sorted_options = sorted(options, key=lambda x: [char for char in x.label if char.isalpha()]) embed.fields = sorted(embed.fields, key=lambda x: x.name.lower()) - view = CategoryView(sorted_options, self.bot, ctx.user, commands) + view = CategoryView(sorted_options, self.bot, ctx.user, commands, ctx) for button in self.bot.help.buttons: view.add_item(deepcopy(button)) await ctx.response.send_message(view=view, embed=embed, ephemeral=self.bot.help.ephemeral) @@ -207,8 +209,11 @@ def __init__( bot: Bot, member: discord.Member | discord.User, commands: dict[str, dict], + interaction, ): - super().__init__(min_values=1, max_values=1, placeholder=t("placeholder"), options=options) + super().__init__( + min_values=1, max_values=1, placeholder=t("placeholder", i=interaction), options=options + ) self.bot = bot self.member = member self.commands = commands @@ -228,7 +233,7 @@ def get_mention(self, cmd) -> str: async def callback(self, interaction: discord.Interaction): if self.bot.help.author_only and interaction.user != self.member: - return await emb.error(interaction, t("wrong_user")) + return await emb.error(interaction, t("wrong_user", i=interaction)) cmds = self.commands[self.values[0]] title = self.values[0].title() @@ -296,9 +301,9 @@ async def callback(self, interaction: discord.Interaction): break if len(commands) == 0: - embed.description = t("no_commands") + embed.description = t("no_commands", i=interaction) - view = CategoryView(self.options, self.bot, self.member, self.commands) + view = CategoryView(self.options, self.bot, self.member, self.commands, interaction) for button in self.bot.help.buttons: view.add_item(deepcopy(button)) await interaction.response.edit_message(embed=embed, view=view) @@ -311,10 +316,11 @@ def __init__( bot: Bot, member: discord.Member | discord.User, commands: dict[str, dict], + interaction: discord.Interaction, ): if PYCORD: super().__init__(timeout=bot.help.timeout, disable_on_timeout=True) else: super().__init__(timeout=None) - self.add_item(CategorySelect(options, bot, member, commands)) + self.add_item(CategorySelect(options, bot, member, commands, interaction)) diff --git a/ezcord/internal/config.py b/ezcord/internal/config.py index 7195881..6d76591 100644 --- a/ezcord/internal/config.py +++ b/ezcord/internal/config.py @@ -23,6 +23,7 @@ class EzConfig: BLACKLIST_COMMANDS = Literal["add", "remove", "show", "owner", "server", "show_servers"] lang: str = "en" + default_lang: str = "de" embed_templates: dict = {} # Blacklist diff --git a/ezcord/internal/language/languages.py b/ezcord/internal/language/languages.py index bd857fd..7ab794d 100644 --- a/ezcord/internal/language/languages.py +++ b/ezcord/internal/language/languages.py @@ -4,6 +4,7 @@ from pathlib import Path from ...logs import log +from ..config import EzConfig @cache @@ -37,7 +38,7 @@ def load_lang(language: str) -> dict[str, dict[str, str]]: lang[category] = {} lang[category][value] = values[value] - if lang == {}: + if EzConfig.lang != "auto" and lang == {}: log.warn(f"Language file for language '{language}' not found. Falling back to 'en'.") return lang diff --git a/ezcord/internal/translation.py b/ezcord/internal/translation.py index 43f2abc..4ddf779 100644 --- a/ezcord/internal/translation.py +++ b/ezcord/internal/translation.py @@ -1,9 +1,12 @@ """Internal language utilities for the library.""" +from __future__ import annotations + import inspect -from functools import cache from pathlib import Path +import discord + from ..logs import log from .config import EzConfig from .language.languages import load_lang @@ -110,7 +113,9 @@ def plural_fr(amount: int, word: str) -> str: return word -def tp(key: str, amount: int, *args: str, relative: bool = True) -> str: +def tp( + key: str, amount: int, *args: str, relative: bool = True, i: discord.Interaction | None = None +) -> str: """Load a string in the selected language and pluralize it. Parameters @@ -123,9 +128,11 @@ def tp(key: str, amount: int, *args: str, relative: bool = True) -> str: The arguments to format the string with. relative: Whether to use relative time. Defaults to ``True``. + i: + The interaction to get the language from. Defaults to ``None``. """ - word = t(key, *args) - lang = get_lang() + word = t(key, *args, i=i) + lang = EzConfig.lang if lang == "de": return plural_de(amount, word, relative) @@ -137,7 +144,7 @@ def tp(key: str, amount: int, *args: str, relative: bool = True) -> str: return plural_en(amount, word) -def t(key: str, *args: str): +def t(key: str, *args: str, i: discord.Interaction | None = None) -> str: """Load a string in the selected language. Parameters @@ -146,6 +153,8 @@ def t(key: str, *args: str): The text to load. *args: The arguments to format the string with. + i: + The interaction to get the language from. Defaults to ``None``. """ n = 1 origin_file = Path(inspect.stack()[n].filename).stem @@ -154,32 +163,20 @@ def t(key: str, *args: str): n += 1 origin_file = Path(inspect.stack()[n].filename).stem - lang = get_lang() + lang = EzConfig.lang + if i and lang == "auto": + locale = i.locale.split("-")[0] + print("LOCALE", locale) + else: + locale = lang try: - string = load_lang(lang)[origin_file][key] - if not string: - return None + lang_dict = load_lang(locale) + string = lang_dict[origin_file][key] return string.format(*args) except KeyError: # fallback to english if the key is not in the custom language file # provided by the user - log.warn(f"Key '{key}' not found in language file '{lang}'. Falling back to 'en'.") + if lang != "auto": + log.warn(f"Key '{key}' not found in language file '{lang}'. Falling back to 'en'.") return load_lang("en")[origin_file][key].format(*args) - - -@cache -def get_lang(): - """Get the language from the config class.""" - return EzConfig.lang - - -def set_lang(lang: str): - """Set the language for the bot. - - Parameters - ---------- - lang: - The language to set. - """ - EzConfig.lang = lang diff --git a/ezcord/times.py b/ezcord/times.py index 5d419e6..b33cc40 100644 --- a/ezcord/times.py +++ b/ezcord/times.py @@ -24,7 +24,9 @@ def set_utc(dt: datetime) -> datetime: return dt.replace(tzinfo=timezone.utc) -def convert_time(seconds: int | float, relative: bool = True) -> str: +def convert_time( + seconds: int | float, relative: bool = True, *, interaction: discord.Interaction | None = None +) -> str: """Convert seconds to a human-readable time. Parameters @@ -42,6 +44,9 @@ def convert_time(seconds: int | float, relative: bool = True) -> str: '5 Tagen' >>> convert_time(450000, relative=False) # Not relative: 5 Tage '5 Tage' + interaction: + The interaction to get the language from. Defaults to ``None``. + If not provided, the language will be set to the default language. Returns ------- @@ -49,18 +54,23 @@ def convert_time(seconds: int | float, relative: bool = True) -> str: A human-readable time. """ if seconds < 60: - return f"{round(seconds)} {tp('sec', round(seconds))}" + return f"{round(seconds)} {tp('sec', round(seconds), i=interaction)}" minutes = seconds / 60 if minutes < 60: - return f"{round(minutes)} {tp('min', round(minutes))}" + return f"{round(minutes)} {tp('min', round(minutes), i=interaction)}" hours = minutes / 60 if hours < 24: - return f"{round(hours)} {tp('hour', round(hours))}" + return f"{round(hours)} {tp('hour', round(hours), i=interaction)}" days = hours / 24 - return f"{round(days)} {tp('day', round(days), relative=relative)}" + return f"{round(days)} {tp('day', round(days), relative=relative, i=interaction)}" -def convert_dt(dt: datetime | timedelta, relative: bool = True) -> str: +def convert_dt( + dt: datetime | timedelta, + relative: bool = True, + *, + interaction: discord.Interaction | None = None, +) -> str: """Convert :class:`datetime` or :class:`timedelta` to a human-readable time. This function calls :func:`convert_time`. @@ -71,6 +81,9 @@ def convert_dt(dt: datetime | timedelta, relative: bool = True) -> str: The datetime or timedelta object to convert. relative: Whether to use relative time. Defaults to ``True``. + interaction: + The interaction to get the language from. Defaults to ``None``. + If not provided, the language will be set to the default language. Returns ------- @@ -78,13 +91,15 @@ def convert_dt(dt: datetime | timedelta, relative: bool = True) -> str: A human-readable time. """ if isinstance(dt, timedelta): - return convert_time(abs(dt.total_seconds()), relative) + return convert_time(abs(dt.total_seconds()), relative, interaction=interaction) if isinstance(dt, datetime): if dt.tzinfo is None: dt = dt.astimezone() - return convert_time(abs((dt - discord.utils.utcnow()).total_seconds()), relative) + return convert_time( + abs((dt - discord.utils.utcnow()).total_seconds()), relative, interaction=interaction + ) def dc_timestamp( diff --git a/ezcord/utils.py b/ezcord/utils.py index 7aef4a1..bff6f62 100644 --- a/ezcord/utils.py +++ b/ezcord/utils.py @@ -10,7 +10,7 @@ import random from typing import Any -from .internal import get_lang +from .internal import EzConfig from .internal.dc import discord @@ -97,7 +97,13 @@ def random_avatar() -> str: return f"https://cdn.discordapp.com/embed/avatars/{random.randint(0, 5)}.png" -def codeblock(content: int | str, *, lang: str = "yaml", unit: str = "") -> str: +def codeblock( + content: int | str, + *, + lang: str = "yaml", + unit: str = "", + interaction: discord.Interaction | None = None, +) -> str: """Returns a codeblock with the given content. Parameters @@ -109,11 +115,17 @@ def codeblock(content: int | str, *, lang: str = "yaml", unit: str = "") -> str: The language of the codeblock. Defaults to ``yaml``. unit: The text to display after the given content. This is only used if the content is an integer. + interaction: + The interaction to get the language from. Defaults to ``None``. + If not provided, the language will be set to the default language. + The language will determine how large numbers are formatted. """ if isinstance(content, int): number = f"{content:,}" - if get_lang() == "de": + if EzConfig.lang == "de" or ( + interaction and EzConfig.lang == "auto" and interaction.locale == "de" + ): number = number.replace(",", ".") block = f"```{lang}\n{number}" if unit: From 36c2c26e5396a33f183c89c59076b0aa4c329f90 Mon Sep 17 00:00:00 2001 From: Timo <35654063+tibue99@users.noreply.github.com> Date: Tue, 2 Jan 2024 12:38:52 +0100 Subject: [PATCH 2/7] Check guild_locale --- ezcord/internal/translation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ezcord/internal/translation.py b/ezcord/internal/translation.py index 4ddf779..04922aa 100644 --- a/ezcord/internal/translation.py +++ b/ezcord/internal/translation.py @@ -165,8 +165,8 @@ def t(key: str, *args: str, i: discord.Interaction | None = None) -> str: lang = EzConfig.lang if i and lang == "auto": - locale = i.locale.split("-")[0] - print("LOCALE", locale) + locale = i.guild_locale if i.guild_locale else i.locale + locale = locale.split("-")[0] else: locale = lang From a538a9eb091a60b8e479ad8813fb6408193c4fa3 Mon Sep 17 00:00:00 2001 From: Timo <35654063+tibue99@users.noreply.github.com> Date: Tue, 2 Jan 2024 13:03:25 +0100 Subject: [PATCH 3/7] Add dynamic language to doc example --- docs/examples/languages.rst | 11 ++++++++++- ezcord/bot.py | 4 ++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/examples/languages.rst b/docs/examples/languages.rst index a3bcc63..4f00a4f 100644 --- a/docs/examples/languages.rst +++ b/docs/examples/languages.rst @@ -18,7 +18,7 @@ Modify language files - If you want to modify the English language file: ``ez_en.json``. - If you want to create a new language file: ``ez_[language].json``. - For example, if you want to create a French language file, the file name could be ``ez_fr.json``. + If you want to create a French language file, the file name could be ``ez_fr.json``. 2. Search the :ref:`language files ` and find keys you want to override. @@ -30,6 +30,15 @@ Modify language files bot = ezcord.Bot(language="fr") # French (loaded from ez_fr.json) +If your bot supports **multiple languages**, set ``language`` to ``auto`` to +automatically detect the language. You can set a fallback language with ``default_language``. + +The fallback language is used when no language file is found for the detected language. + +.. code-block:: python + + bot = ezcord.Bot(language="auto", default_language="en") + .. _language: diff --git a/ezcord/bot.py b/ezcord/bot.py index 7a6bd48..01b81c5 100644 --- a/ezcord/bot.py +++ b/ezcord/bot.py @@ -74,8 +74,8 @@ class Bot(_main_bot): # type: ignore Whether to send the full error traceback. If this is ``False``, only the most recent traceback will be sent. Defaults to ``False``. language: - The language to use for user output. If this is set to ``"auto"``, - the bot will try to use the language of the interaction locale. + The language to use for user output. If this is set to ``auto``, + the bot will use the language of the interaction locale whenever possible. default_language: The default language to use if the interaction locale is not available. Defaults to ``"en"``. ``en`` and ``de`` are available by default, but you can add your own From 2590c764673088a6b7896232aa59e2c25cdc3b40 Mon Sep 17 00:00:00 2001 From: Timo <35654063+tibue99@users.noreply.github.com> Date: Tue, 2 Jan 2024 13:16:49 +0100 Subject: [PATCH 4/7] Load default lang faster is auto language is not available --- ezcord/internal/language/languages.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ezcord/internal/language/languages.py b/ezcord/internal/language/languages.py index 7ab794d..a13ccc9 100644 --- a/ezcord/internal/language/languages.py +++ b/ezcord/internal/language/languages.py @@ -11,6 +11,9 @@ def load_lang(language: str) -> dict[str, dict[str, str]]: """Loads the default language file and checks if the user provided a custom language file.""" + if language == "auto": + language = EzConfig.default_lang + lang = {} parent = Path(__file__).parent.absolute() for element in os.scandir(parent): From f5283cdfc39c1ffaf70bc1c7e7ce037b89a18ba0 Mon Sep 17 00:00:00 2001 From: Timo <35654063+tibue99@users.noreply.github.com> Date: Tue, 2 Jan 2024 13:20:46 +0100 Subject: [PATCH 5/7] Fix docstring --- ezcord/internal/language/languages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ezcord/internal/language/languages.py b/ezcord/internal/language/languages.py index a13ccc9..0cd5cc2 100644 --- a/ezcord/internal/language/languages.py +++ b/ezcord/internal/language/languages.py @@ -9,7 +9,7 @@ @cache def load_lang(language: str) -> dict[str, dict[str, str]]: - """Loads the default language file and checks if the user provided a custom language file.""" + """Loads the given language file and checks if the user provided a custom language file.""" if language == "auto": language = EzConfig.default_lang From 78c2dfac5ec0939c3818e63f223bc87787739e4a Mon Sep 17 00:00:00 2001 From: Timo <35654063+tibue99@users.noreply.github.com> Date: Tue, 2 Jan 2024 13:33:34 +0100 Subject: [PATCH 6/7] Fix imports --- ezcord/cogs/blacklist.py | 1 - ezcord/internal/translation.py | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/ezcord/cogs/blacklist.py b/ezcord/cogs/blacklist.py index 6e6bebc..ab7e388 100644 --- a/ezcord/cogs/blacklist.py +++ b/ezcord/cogs/blacklist.py @@ -6,7 +6,6 @@ """ import aiosqlite -import discord from .. import emb from ..blacklist import _BanDB diff --git a/ezcord/internal/translation.py b/ezcord/internal/translation.py index 04922aa..8b2fba1 100644 --- a/ezcord/internal/translation.py +++ b/ezcord/internal/translation.py @@ -5,8 +5,7 @@ import inspect from pathlib import Path -import discord - +from ..internal.dc import discord from ..logs import log from .config import EzConfig from .language.languages import load_lang From 7adc2b4808d07ddb34702e643f29d8125bf1dfc9 Mon Sep 17 00:00:00 2001 From: Timo <35654063+tibue99@users.noreply.github.com> Date: Tue, 2 Jan 2024 13:56:44 +0100 Subject: [PATCH 7/7] Refactor locale --- ezcord/internal/translation.py | 19 ++++++++++++++----- ezcord/utils.py | 6 ++---- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/ezcord/internal/translation.py b/ezcord/internal/translation.py index 8b2fba1..e5ecab6 100644 --- a/ezcord/internal/translation.py +++ b/ezcord/internal/translation.py @@ -143,6 +143,19 @@ def tp( return plural_en(amount, word) +def get_locale(interaction: discord.Interaction | None) -> str: + if not interaction and EzConfig.lang == "auto": + return EzConfig.default_lang + + if interaction and EzConfig.lang == "auto": + locale = interaction.guild_locale if interaction.guild_locale else interaction.locale + locale = locale.split("-")[0] + else: + locale = EzConfig.lang + + return locale + + def t(key: str, *args: str, i: discord.Interaction | None = None) -> str: """Load a string in the selected language. @@ -163,11 +176,7 @@ def t(key: str, *args: str, i: discord.Interaction | None = None) -> str: origin_file = Path(inspect.stack()[n].filename).stem lang = EzConfig.lang - if i and lang == "auto": - locale = i.guild_locale if i.guild_locale else i.locale - locale = locale.split("-")[0] - else: - locale = lang + locale = get_locale(i) try: lang_dict = load_lang(locale) diff --git a/ezcord/utils.py b/ezcord/utils.py index 98ba9e7..a3d2428 100644 --- a/ezcord/utils.py +++ b/ezcord/utils.py @@ -12,7 +12,7 @@ from pathlib import Path from typing import Any -from .internal import EzConfig +from .internal import get_locale from .internal.dc import discord @@ -125,9 +125,7 @@ def codeblock( if isinstance(content, int): number = f"{content:,}" - if EzConfig.lang == "de" or ( - interaction and EzConfig.lang == "auto" and interaction.locale == "de" - ): + if get_locale(interaction) == "de": number = number.replace(",", ".") block = f"```{lang}\n{number}" if unit: