From eb0731e18518d69dffc2e9c985eaf74142917b28 Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 5 Jun 2023 15:51:03 +0300 Subject: [PATCH] rewrite (babel): Complete configuration UI. --- src/babel/cog.py | 284 +++++++++++++------------------- src/babel/enums.py | 37 +++++ src/babel/settings.py | 144 ++++++++++++++++ src/babel/settingui.py | 86 ++++++++++ src/babel/translator.py | 1 + src/bot.py | 4 +- src/modules/config/dashboard.py | 3 +- src/modules/user_config/cog.py | 8 + 8 files changed, 397 insertions(+), 170 deletions(-) create mode 100644 src/babel/settings.py create mode 100644 src/babel/settingui.py diff --git a/src/babel/cog.py b/src/babel/cog.py index 3b2b9992..3435b0f3 100644 --- a/src/babel/cog.py +++ b/src/babel/cog.py @@ -8,138 +8,23 @@ from typing import Optional import discord from discord.ext import commands as cmds from discord import app_commands as appcmds +from discord.ui.button import ButtonStyle from meta import LionBot, LionCog, LionContext from meta.errors import UserInputError +from utils.ui import AButton, AsComponents from wards import low_management -from settings import ModelData -from settings.setting_types import StringSetting, BoolSetting -from settings.groups import SettingGroup - -from core.data import CoreData - -from .translator import ctx_locale, ctx_translator, LocalBabel, SOURCE_LOCALE +from .translator import ctx_locale, ctx_translator, SOURCE_LOCALE from . import babel +from .enums import locale_names +from .settings import LocaleSettings +from .settingui import LocaleSettingUI _ = babel._ _p = babel._p -class LocaleSettings(SettingGroup): - class UserLocale(ModelData, StringSetting): - """ - User-configured locale. - - Exposed via dedicated setting command. - """ - setting_id = 'user_locale' - - _display_name = _p('userset:locale', 'language') - _desc = _p('userset:locale|desc', "Your preferred language for interacting with me.") - - _model = CoreData.User - _column = CoreData.User.locale.name - - @property - def update_message(self): - t = ctx_translator.get().t - if self.data is None: - return t(_p('userset:locale|response', "You have unset your language.")) - else: - return t(_p('userset:locale|response', "You have set your language to `{lang}`.")).format( - lang=self.data - ) - - @classmethod - async def _parse_string(cls, parent_id, string, **kwargs): - translator = ctx_translator.get() - if string not in translator.supported_locales: - lang = string[:20] - raise UserInputError( - translator.t( - _p('userset:locale|error', "Sorry, we do not support the `{lang}` language at this time!") - ).format(lang=lang) - ) - return string - - class ForceLocale(ModelData, BoolSetting): - """ - Guild configuration for whether to force usage of the guild locale. - - Exposed via `/configure language` command and standard configuration interface. - """ - setting_id = 'force_locale' - - _display_name = _p('guildset:force_locale', 'force_language') - _desc = _p('guildset:force_locale|desc', - "Whether to force all members to use the configured guild language when interacting with me.") - long_desc = _p( - 'guildset:force_locale|long_desc', - "When enabled, commands in this guild will always use the configured guild language, " - "regardless of the member's personally configured language." - ) - _outputs = { - True: _p('guildset:force_locale|output', 'Enabled (members will be forced to use the server language)'), - False: _p('guildset:force_locale|output', 'Disabled (members may set their own language)'), - None: 'Not Set' # This should be impossible, since we have a default - } - _default = False - - _model = CoreData.Guild - _column = CoreData.Guild.force_locale.name - - @property - def update_message(self): - t = ctx_translator.get().t - if self.data: - return t(_p( - 'guildset:force_locale|response', - "I will always use the set language in this server." - )) - else: - return t(_p( - 'guildset:force_locale|response', - "I will now allow the members to set their own language here." - )) - - class GuildLocale(ModelData, StringSetting): - """ - Guild-configured locale. - - Exposed via `/configure language` command, and standard configuration interface. - """ - setting_id = 'guild_locale' - - _display_name = _p('guildset:locale', 'language') - _desc = _p('guildset:locale|desc', "Your preferred language for interacting with me.") - - _model = CoreData.Guild - _column = CoreData.Guild.locale.name - - @property - def update_message(self): - t = ctx_translator.get().t - if self.data is None: - return t(_p('guildset:locale|response', "You have reset the guild language.")) - else: - return t(_p('guildset:locale|response', "You have set the guild language to `{lang}`.")).format( - lang=self.data - ) - - @classmethod - async def _parse_string(cls, parent_id, string, **kwargs): - translator = ctx_translator.get() - if string not in translator.supported_locales: - lang = string[:20] - raise UserInputError( - translator.t( - _p('guildset:locale|error', "Sorry, we do not support the `{lang}` language at this time!") - ).format(lang=lang) - ) - return string - - class BabelCog(LionCog): depends = {'CoreCog'} @@ -158,6 +43,9 @@ class BabelCog(LionCog): configcog = self.bot.get_cog('ConfigCog') self.crossload_group(self.configure_group, configcog.configure_group) + userconfigcog = self.bot.get_cog('UserConfigCog') + self.crossload_group(self.userconfig_group, userconfigcog.userconfig_group) + async def cog_unload(self): pass @@ -201,22 +89,6 @@ class BabelCog(LionCog): ctx_translator.set(self.bot.translator) return True - @cmds.hybrid_command( - name=LocaleSettings.UserLocale._display_name, - description=LocaleSettings.UserLocale._desc - ) - async def cmd_language(self, ctx: LionContext, language: str): - """ - Dedicated user setting command for the `locale` setting. - """ - if not ctx.interaction: - # This command is not available as a text command - return - - setting = await self.settings.UserLocale.get(ctx.author.id) - new_data = await setting._parse_string(ctx.author.id, language) - await setting.interactive_set(new_data, ctx.interaction) - @LionCog.placeholder_group @cmds.hybrid_group('configure', with_app_command=False) async def configure_group(self, ctx: LionContext): @@ -244,9 +116,9 @@ class BabelCog(LionCog): ) @appcmds.guild_only() # Can be removed when attached as a subcommand @cmds.check(low_management) - async def cmd_configure_language( - self, ctx: LionContext, language: Optional[str] = None, force_language: Optional[appcmds.Choice[int]] = None - ): + async def cmd_configure_language(self, ctx: LionContext, + language: Optional[str] = None, + force_language: Optional[appcmds.Choice[int]] = None): if not ctx.interaction: # This command is not available as a text command return @@ -284,36 +156,112 @@ class BabelCog(LionCog): force_setting.data = force_data await force_setting.write() lines.append(force_setting.update_message) - result = '\n'.join( - f"{self.bot.config.emojis.tick} {line}" for line in lines - ) - # TODO: Setting group widget - await ctx.reply( - embed=discord.Embed( - colour=discord.Colour.green(), - title=t(_p('cmd:configure_language|success', "Language settings updated!")), - description=result + if lines: + result = '\n'.join( + f"{self.bot.config.emojis.tick} {line}" for line in lines ) - ) - - @cmd_configure_language.autocomplete('language') - async def cmd_configure_language_acmpl_language(self, interaction: discord.Interaction, partial: str): - # TODO: More friendly language names - supported = self.bot.translator.supported_locales - matching = [lang for lang in supported if partial.lower() in lang] - t = self.t - if not matching: - return [ - appcmds.Choice( - name=t(_p( - 'cmd:configure_language|acmpl:language', - "No supported languages matching {partial}" - )).format(partial=partial), - value='None' + await ctx.reply( + embed=discord.Embed( + colour=discord.Colour.green(), + title=t(_p('cmd:configure_language|success', "Language settings updated!")), + description=result ) + ) + + if ctx.channel.id not in LocaleSettingUI._listening or not lines: + ui = LocaleSettingUI(self.bot, ctx.guild.id, ctx.channel.id) + await ui.run(ctx.interaction) + await ui.wait() + + @LionCog.placeholder_group + @cmds.hybrid_group(name='my') + async def userconfig_group(self, ctx: LionContext): + pass + + @userconfig_group.command( + name=_p('cmd:userconfig_language', "language"), + description=_p( + 'cmd:userconfig_language|desc', + "Set your preferred interaction language." + ) + ) + @appcmds.rename( + language=_p('cmd:userconfig_language|param:language', "language") + ) + @appcmds.describe( + language=_p( + 'cmd:userconfig_language|param:language|desc', + "Which language do you want me to respond in?" + ) + ) + async def userconfig_language_cmd(self, ctx: LionContext, language: Optional[str] = None): + if not ctx.interaction: + return + t = self.bot.translator.t + + setting = await self.settings.UserLocale.get(ctx.author.id) + if language: + new_data = await setting._parse_string(ctx.author.id, language) + await setting.interactive_set(new_data, ctx.interaction, ephemeral=True) + else: + if setting.value: + desc = t(_p( + 'cmd:userconfig_language|response:set', + "Your preferred language is currently set to {language}" + )).format(language=setting.formatted) + + @AButton( + label=t(_p('cmd:userconfig_language|button:reset|label', "Reset")), + style=ButtonStyle.red + ) + async def reset_button(_press: discord.Interaction, pressed): + await _press.response.defer() + await setting.interactive_set(None, ctx.interaction, view=None) + + view = AsComponents(reset_button) + else: + desc = t(_p( + 'cmd:userconfig_language|response:unset', + "You have not set a preferred language!" + )) + view = None + embed = discord.Embed( + colour=discord.Colour.orange(), + description=desc + ) + await ctx.reply(embed=embed, ephemeral=True, view=view) + + @userconfig_language_cmd.autocomplete('language') + @cmd_configure_language.autocomplete('language') + async def acmpl_language(self, interaction: discord.Interaction, partial: str): + """ + Shared autocomplete for language options. + """ + t = self.bot.translator.t + supported = self.bot.translator.supported_locales + formatted = [] + for locale in supported: + name = locale_names.get(locale, None) + if name: + localestr = f"{locale} ({t(name)})" + else: + localestr = locale + formatted.append((locale, localestr)) + + matching = {item for item in formatted if partial in item[1]} + if matching: + choices = [ + appcmds.Choice(name=localestr, value=locale) + for locale, localestr in matching ] else: - return [ - appcmds.Choice(name=lang, value=lang) - for lang in matching + choices = [ + appcmds.Choice( + name=t(_p( + 'acmpl:language|no_match', + "No supported languages matching {partial}" + )).format(partial=partial), + value=partial + ) ] + return choices diff --git a/src/babel/enums.py b/src/babel/enums.py index 45df5ffd..90285985 100644 --- a/src/babel/enums.py +++ b/src/babel/enums.py @@ -1,4 +1,7 @@ from enum import Enum +from . import babel + +_p = babel._p class LocaleMap(Enum): @@ -32,3 +35,37 @@ class LocaleMap(Enum): turkish = 'tr' ukrainian = 'uk' vietnamese = 'vi' + + +locale_names = { + 'en-US': _p('localenames|locale:en-US', "American English"), + 'en-GB': _p('localenames|locale:en-GB', "British English"), + 'bg': _p('localenames|locale:bg', "Bulgarian"), + 'zh-CN': _p('localenames|locale:zh-CN', "Chinese"), + 'zh-TW': _p('localenames|locale:zh-TW', "Taiwan Chinese"), + 'hr': _p('localenames|locale:hr', "Croatian"), + 'cs': _p('localenames|locale:cs', "Czech"), + 'da': _p('localenames|locale:da', "Danish"), + 'nl': _p('localenames|locale:nl', "Dutch"), + 'fi': _p('localenames|locale:fi', "Finnish"), + 'fr': _p('localenames|locale:fr', "French"), + 'de': _p('localenames|locale:de', "German"), + 'el': _p('localenames|locale:el', "Greek"), + 'hi': _p('localenames|locale:hi', "Hindi"), + 'hu': _p('localenames|locale:hu', "Hungarian"), + 'it': _p('localenames|locale:it', "Italian"), + 'ja': _p('localenames|locale:ja', "Japanese"), + 'ko': _p('localenames|locale:ko', "Korean"), + 'lt': _p('localenames|locale:lt', "Lithuanian"), + 'no': _p('localenames|locale:no', "Norwegian"), + 'pl': _p('localenames|locale:pl', "Polish"), + 'pt-BR': _p('localenames|locale:pt-BR', "Brazil Portuguese"), + 'ro': _p('localenames|locale:ro', "Romanian"), + 'ru': _p('localenames|locale:ru', "Russian"), + 'es-ES': _p('localenames|locale:es-ES', "Spain Spanish"), + 'sv-SE': _p('localenames|locale:sv-SE', "Swedish"), + 'th': _p('localenames|locale:th', "Thai"), + 'tr': _p('localenames|locale:tr', "Turkish"), + 'uk': _p('localenames|locale:uk', "Ukrainian"), + 'vi': _p('localenames|locale:vi', "Vietnamese"), +} diff --git a/src/babel/settings.py b/src/babel/settings.py new file mode 100644 index 00000000..539f3ff8 --- /dev/null +++ b/src/babel/settings.py @@ -0,0 +1,144 @@ + +from settings import ModelData +from settings.setting_types import StringSetting, BoolSetting +from settings.groups import SettingGroup + +from meta.errors import UserInputError +from core.data import CoreData + +from .translator import ctx_translator +from . import babel +from .enums import locale_names + +_p = babel._p + + +class LocaleSetting(StringSetting): + """ + Base class describing a LocaleSetting. + """ + @classmethod + def _format_data(cls, parent_id, data, **kwargs): + t = ctx_translator.get().t + if data is None: + formatted = t(_p('set_type:locale|formatted:unset', "Unset")) + else: + name = locale_names.get(data, None) + if name: + formatted = f"`{data} ({t(name)})`" + else: + formatted = f"`{data}`" + return formatted + + @classmethod + async def _parse_string(cls, parent_id, string, **kwargs): + translator = ctx_translator.get() + if string not in translator.supported_locales: + lang = string[:20] + raise UserInputError( + translator.t( + _p('set_type:locale|error', "Sorry, we do not support the language `{lang}` at this time!") + ).format(lang=lang) + ) + return string + + +class LocaleSettings(SettingGroup): + class UserLocale(ModelData, LocaleSetting): + """ + User-configured locale. + + Exposed via dedicated setting command. + """ + setting_id = 'user_locale' + + _display_name = _p('userset:locale', 'language') + _desc = _p('userset:locale|desc', "Your preferred language for interacting with me.") + + _model = CoreData.User + _column = CoreData.User.locale.name + + @property + def update_message(self): + t = ctx_translator.get().t + if self.data is None: + return t(_p('userset:locale|response', "You have unset your language.")) + else: + return t(_p('userset:locale|response', "You have set your language to {lang}.")).format( + lang=self.formatted + ) + + class ForceLocale(ModelData, BoolSetting): + """ + Guild configuration for whether to force usage of the guild locale. + + Exposed via `/configure language` command and standard configuration interface. + """ + setting_id = 'force_locale' + + _display_name = _p('guildset:force_locale', 'force_language') + _desc = _p('guildset:force_locale|desc', + "Whether to force all members to use the configured guild language when interacting with me.") + _long_desc = _p( + 'guildset:force_locale|long_desc', + "When enabled, commands in this guild will always use the configured guild language, " + "regardless of the member's personally configured language." + ) + _outputs = { + True: _p('guildset:force_locale|output', 'Enabled (members will be forced to use the server language)'), + False: _p('guildset:force_locale|output', 'Disabled (members may set their own language)'), + None: 'Not Set' # This should be impossible, since we have a default + } + _default = False + + _model = CoreData.Guild + _column = CoreData.Guild.force_locale.name + + @property + def update_message(self): + t = ctx_translator.get().t + if self.data: + return t(_p( + 'guildset:force_locale|response', + "I will always use the set language in this server." + )) + else: + return t(_p( + 'guildset:force_locale|response', + "I will now allow the members to set their own language here." + )) + + @classmethod + def _format_data(cls, parent_id, data, **kwargs): + t = ctx_translator.get().t + return t(cls._outputs[data]) + + class GuildLocale(ModelData, LocaleSetting): + """ + Guild-configured locale. + + Exposed via `/configure language` command, and standard configuration interface. + """ + setting_id = 'guild_locale' + + _display_name = _p('guildset:locale', 'language') + _desc = _p('guildset:locale|desc', "Your preferred language for interacting with me.") + _long_desc = _p( + 'guildset:locale|long_desc', + "The default language to use for responses and interactions in this server. " + "Member's own configured language will override this for their commands " + "unless `force_language` is enabled." + ) + + _model = CoreData.Guild + _column = CoreData.Guild.locale.name + + @property + def update_message(self): + t = ctx_translator.get().t + if self.data is None: + return t(_p('guildset:locale|response', "You have unset the guild language.")) + else: + return t(_p('guildset:locale|response', "You have set the guild language to {lang}.")).format( + lang=self.formatted + ) diff --git a/src/babel/settingui.py b/src/babel/settingui.py new file mode 100644 index 00000000..80cb813f --- /dev/null +++ b/src/babel/settingui.py @@ -0,0 +1,86 @@ +import asyncio + +import discord +from discord.ui.button import button, Button, ButtonStyle + +from meta import LionBot + +from utils.ui import ConfigUI, MessageUI, DashboardSection +from utils.lib import MessageArgs + +from .settings import LocaleSettings +from . import babel + +_p = babel._p + + +class LocaleSettingUI(ConfigUI): + setting_classes = [ + LocaleSettings.GuildLocale, + LocaleSettings.ForceLocale, + ] + + def __init__(self, bot: LionBot, guildid: int, channelid: int, **kwargs): + self.settings = bot.get_cog('BabelCog').settings + super().__init__(bot, guildid, channelid, **kwargs) + + # ----- UI Components ----- + @button(label="FORCE_BUTTON_PLACEHOLDER", style=ButtonStyle.grey) + async def force_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer() + setting = next(inst for inst in self.instances if inst.setting_id == LocaleSettings.ForceLocale.setting_id) + setting.value = not setting.value + await setting.write() + + async def force_button_refresh(self): + button = self.force_button + setting = next(inst for inst in self.instances if inst.setting_id == LocaleSettings.ForceLocale.setting_id) + button.label = self.bot.translator.t(_p( + 'ui:locale_config|button:force|label', + "Toggle Force" + )) + button.style = ButtonStyle.green if setting.value else ButtonStyle.grey + + # ----- UI Flow ----- + async def make_message(self) -> MessageArgs: + t = self.bot.translator.t + title = t(_p( + 'ui:locale_config|embed|title', + "Language Configuration Panel" + )) + embed = discord.Embed( + colour=discord.Colour.orange(), + title=title + ) + for setting in self.instances: + embed.add_field(**setting.embed_field, inline=False) + + args = MessageArgs(embed=embed) + return args + + async def reload(self): + lguild = await self.bot.core.lions.fetch_guild(self.guildid) + self.instances = tuple( + lguild.config.get(setting.setting_id) + for setting in self.setting_classes + ) + + async def refresh_components(self): + await asyncio.gather( + self.force_button_refresh(), + self.edit_button_refresh(), + self.close_button_refresh(), + self.reset_button_refresh(), + ) + self.set_layout( + (self.force_button, self.edit_button, self.reset_button, self.close_button) + ) + + +class LocaleDashboard(DashboardSection): + section_name = _p( + 'dash:locale|title', + "Server Language Configuration" + ) + configui = LocaleSettingUI + setting_classes = LocaleSettingUI.setting_classes diff --git a/src/babel/translator.py b/src/babel/translator.py index 0f8cb778..0d4141b3 100644 --- a/src/babel/translator.py +++ b/src/babel/translator.py @@ -33,6 +33,7 @@ class LeoBabel(Translator): locales = conf.babel.get('locales', '') stripped = (loc.strip(', ') for loc in locales.split(',')) self.supported_locales = {loc for loc in stripped if loc} + self.supported_locales.add(SOURCE_LOCALE) domains = conf.babel.get('domains', '') stripped = (dom.strip(', ') for dom in domains.split(',')) diff --git a/src/bot.py b/src/bot.py index 4fe7916c..bd347b3c 100644 --- a/src/bot.py +++ b/src/bot.py @@ -53,8 +53,9 @@ async def main(): db=db, config=conf, initial_extensions=[ - 'utils', 'core', 'analytics', 'babel', + 'utils', 'core', 'analytics', 'modules', + 'babel', 'tracking.voice', 'tracking.text', ], web_client=session, @@ -62,6 +63,7 @@ async def main(): testing_guilds=conf.bot.getintlist('admin_guilds'), shard_id=sharding.shard_number, shard_count=sharding.shard_count, + help_command=None, translator=translator ) as lionbot: ctx_bot.set(lionbot) diff --git a/src/modules/config/dashboard.py b/src/modules/config/dashboard.py index dbb5a1f8..22347c7d 100644 --- a/src/modules/config/dashboard.py +++ b/src/modules/config/dashboard.py @@ -15,6 +15,7 @@ from tracking.text.ui import TextTrackerDashboard from modules.ranks.ui.config import RankDashboard from modules.pomodoro.settingui import TimerDashboard from modules.rooms.settingui import RoomDashboard +from babel.settingui import LocaleDashboard # from modules.statistics.settings import StatisticsConfigUI from . import babel, logger @@ -28,7 +29,7 @@ class GuildDashboard(BasePager): Paged UI providing an overview of the guild configuration. """ pages = [ - (EconomyDashboard, TasklistDashboard), + (LocaleDashboard, EconomyDashboard, TasklistDashboard), (VoiceTrackerDashboard, TextTrackerDashboard, ), (RankDashboard, TimerDashboard, RoomDashboard, ) ] diff --git a/src/modules/user_config/cog.py b/src/modules/user_config/cog.py index dad24674..6130352a 100644 --- a/src/modules/user_config/cog.py +++ b/src/modules/user_config/cog.py @@ -176,3 +176,11 @@ class UserConfigCog(LionCog): ) choices.append(choice) return choices + + @cmds.hybrid_group( + name=_p('cmd:userconfig', "my"), + description=_p('cmd:userconfig|desc', "User configuration commands.") + ) + async def userconfig_group(self, ctx: LionContext): + # Group base command, no function. + pass