From e1a23695ee19135efbdb9ede856afd974365f745 Mon Sep 17 00:00:00 2001 From: Conatum Date: Tue, 6 Jun 2023 12:57:29 +0300 Subject: [PATCH] rewrite: Setting input strings and localisation. --- .gitignore | 4 +- src/babel/cog.py | 16 +- src/babel/settings.py | 50 +++++- src/babel/settingui.py | 2 +- src/core/cog.py | 14 +- src/modules/config/dashboard.py | 5 +- src/modules/config/general.py | 7 +- src/modules/economy/cog.py | 18 +- src/modules/economy/settings.py | 43 ++++- src/modules/economy/settingui.py | 2 +- src/modules/pomodoro/cog.py | 6 +- src/modules/pomodoro/settings.py | 19 +++ src/modules/pomodoro/settingui.py | 2 +- src/modules/ranks/settings.py | 83 ++++++++-- src/modules/ranks/ui/config.py | 2 +- src/modules/rooms/settings.py | 49 ++++++ src/modules/rooms/settingui.py | 2 +- src/modules/statistics/settings.py | 119 ++++++++++++-- src/modules/sysadmin/__init__.py | 14 +- src/modules/sysadmin/presence.py | 82 ++++++++-- src/modules/tasklist/settings.py | 69 +++++--- src/modules/user_config/cog.py | 1 + src/settings/setting_types.py | 254 ++++++++++++++++++++--------- src/settings/ui.py | 59 +++++-- src/tracking/text/settings.py | 51 ++++++ src/tracking/text/ui.py | 2 +- src/tracking/voice/settings.py | 75 ++++----- src/utils/lib.py | 3 +- src/utils/ui/config.py | 6 +- 29 files changed, 823 insertions(+), 236 deletions(-) diff --git a/.gitignore b/.gitignore index 978964e7..f2747446 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +src/modules/test/* + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -137,4 +139,4 @@ dmypy.json # Cython debug symbols cython_debug/ -config/** \ No newline at end of file +config/** diff --git a/src/babel/cog.py b/src/babel/cog.py index 3435b0f3..9a22151f 100644 --- a/src/babel/cog.py +++ b/src/babel/cog.py @@ -136,7 +136,7 @@ class BabelCog(LionCog): if language: lang_data = await lang_setting._parse_string(ctx.guild.id, language) if force_language is not None: - force_data = bool(force_language) + force_data = bool(force_language.value) if force_language is not None and not (lang_data if language is not None else lang_setting.value): # Setting force without having a language! @@ -204,12 +204,8 @@ class BabelCog(LionCog): new_data = await setting._parse_string(ctx.author.id, language) await setting.interactive_set(new_data, ctx.interaction, ephemeral=True) else: + embed = setting.embed 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 @@ -220,15 +216,7 @@ class BabelCog(LionCog): 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') diff --git a/src/babel/settings.py b/src/babel/settings.py index 539f3ff8..bacfda87 100644 --- a/src/babel/settings.py +++ b/src/babel/settings.py @@ -4,6 +4,7 @@ from settings.setting_types import StringSetting, BoolSetting from settings.groups import SettingGroup from meta.errors import UserInputError +from meta.context import ctx_bot from core.data import CoreData from .translator import ctx_translator @@ -17,11 +18,30 @@ class LocaleSetting(StringSetting): """ Base class describing a LocaleSetting. """ + _accepts = _p( + 'settype:locale|accepts', + "Enter a supported language (e.g. 'en-GB')." + ) + + def _desc_table(self) -> list[str]: + translator = ctx_translator.get() + t = translator.t + + lines = super()._desc_table() + lines.append(( + t(_p( + 'settype:locale|summary_table|field:supported|key', + "Supported" + )), + ', '.join(f"`{locale}`" for locale in translator.supported_locales) + )) + return lines + @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")) + formatted = t(_p('settype:locale|formatted:unset', "Unset")) else: name = locale_names.get(data, None) if name: @@ -37,7 +57,7 @@ class LocaleSetting(StringSetting): lang = string[:20] raise UserInputError( translator.t( - _p('set_type:locale|error', "Sorry, we do not support the language `{lang}` at this time!") + _p('settype:locale|error', "Sorry, we do not support the language `{lang}` at this time!") ).format(lang=lang) ) return string @@ -54,6 +74,11 @@ class LocaleSettings(SettingGroup): _display_name = _p('userset:locale', 'language') _desc = _p('userset:locale|desc', "Your preferred language for interacting with me.") + _long_desc = _p( + 'userset:locale|long_desc', + "The language you would prefer me to respond to commands and interactions in. " + "Servers may be configured to override this with their own language." + ) _model = CoreData.User _column = CoreData.User.locale.name @@ -68,6 +93,12 @@ class LocaleSettings(SettingGroup): lang=self.formatted ) + @property + def set_str(self): + bot = ctx_bot.get() + if bot: + return bot.core.mention_cmd('my language') + class ForceLocale(ModelData, BoolSetting): """ Guild configuration for whether to force usage of the guild locale. @@ -108,10 +139,11 @@ class LocaleSettings(SettingGroup): "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]) + @property + def set_str(self): + bot = ctx_bot.get() + if bot: + return bot.core.mention_cmd('configure language') class GuildLocale(ModelData, LocaleSetting): """ @@ -142,3 +174,9 @@ class LocaleSettings(SettingGroup): return t(_p('guildset:locale|response', "You have set the guild language to {lang}.")).format( lang=self.formatted ) + + @property + def set_str(self): + bot = ctx_bot.get() + if bot: + return bot.core.mention_cmd('configure language') diff --git a/src/babel/settingui.py b/src/babel/settingui.py index 80cb813f..b5810adf 100644 --- a/src/babel/settingui.py +++ b/src/babel/settingui.py @@ -80,7 +80,7 @@ class LocaleSettingUI(ConfigUI): class LocaleDashboard(DashboardSection): section_name = _p( 'dash:locale|title', - "Server Language Configuration" + "Server Language Configuration ({commands[configure language]})" ) configui = LocaleSettingUI setting_classes = LocaleSettingUI.setting_classes diff --git a/src/core/cog.py b/src/core/cog.py index 2050732a..617d8bf8 100644 --- a/src/core/cog.py +++ b/src/core/cog.py @@ -1,4 +1,5 @@ from typing import Optional +from collections import defaultdict import discord import discord.app_commands as appcmd @@ -17,6 +18,15 @@ from .lion_member import MemberConfig from .lion_user import UserConfig +class keydefaultdict(defaultdict): + def __missing__(self, key): + if self.default_factory is None: + raise KeyError(key) + else: + ret = self[key] = self.default_factory(key) + return ret + + class CoreCog(LionCog): def __init__(self, bot: LionBot): self.bot = bot @@ -43,7 +53,7 @@ class CoreCog(LionCog): self.app_cmd_cache: list[discord.app_commands.AppCommand] = [] self.cmd_name_cache: dict[str, discord.app_commands.AppCommand] = {} - self.mention_cache: dict[str, str] = {} + self.mention_cache: dict[str, str] = keydefaultdict(self.mention_cmd) async def cog_load(self): # Fetch (and possibly create) core data rows. @@ -74,7 +84,7 @@ class CoreCog(LionCog): self.mention_cache = self._mention_cache_from(self.app_cmd_cache) def _mention_cache_from(self, cmds: list[appcmd.AppCommand | appcmd.AppCommandGroup]): - cache = {} + cache = keydefaultdict(self.mention_cmd) for cmd in cmds: cache[cmd.qualified_name if isinstance(cmd, appcmd.AppCommandGroup) else cmd.name] = cmd.mention subcommands = [option for option in cmd.options if isinstance(option, appcmd.AppCommandGroup)] diff --git a/src/modules/config/dashboard.py b/src/modules/config/dashboard.py index 22347c7d..cb62c35e 100644 --- a/src/modules/config/dashboard.py +++ b/src/modules/config/dashboard.py @@ -141,7 +141,10 @@ class GuildDashboard(BasePager): for i, page in enumerate(self.pages): for j, section in enumerate(page): option = SelectOption( - label=t(section.section_name), + label=t(section.section_name).format( + bot=self.bot, + commands=self.bot.core.mention_cache + ), value=str(i * 10 + j) ) options.append(option) diff --git a/src/modules/config/general.py b/src/modules/config/general.py index db952fb2..3073a95b 100644 --- a/src/modules/config/general.py +++ b/src/modules/config/general.py @@ -11,7 +11,7 @@ import discord from discord.ext import commands as cmds from discord import app_commands as appcmds -from meta import LionBot, LionCog, LionContext +from meta import LionBot, LionCog, LionContext, ctx_bot from meta.errors import UserInputError from wards import low_management from settings import ModelData @@ -57,7 +57,6 @@ class GeneralSettings(SettingGroup): @property def update_message(self): t = ctx_translator.get().t - # TODO: update_message can state time in current timezone return t(_p( 'guildset:timezone|response', "The guild timezone has been set to `{timezone}`." @@ -65,8 +64,8 @@ class GeneralSettings(SettingGroup): @property def set_str(self): - # TODO - return '' + bot = ctx_bot.get() + return bot.core.mention_cmd('configure general') if bot else None class GeneralSettingsCog(LionCog): diff --git a/src/modules/economy/cog.py b/src/modules/economy/cog.py index e8cd4223..ccec2031 100644 --- a/src/modules/economy/cog.py +++ b/src/modules/economy/cog.py @@ -787,9 +787,23 @@ class Economy(LionCog): "Configure LionCoin Economy" ) ) + @appcmds.rename( + allow_transfers=EconomySettings.AllowTransfers._display_name, + coins_per_xp=EconomySettings.CoinsPerXP._display_name + ) + @appcmds.describe( + allow_transfers=EconomySettings.AllowTransfers._desc, + coins_per_xp=EconomySettings.CoinsPerXP._desc + ) + @appcmds.choices( + allow_transfers=[ + appcmds.Choice(name=EconomySettings.AllowTransfers._outputs[True], value=1), + appcmds.Choice(name=EconomySettings.AllowTransfers._outputs[False], value=0), + ] + ) @cmds.check(low_management) async def configure_economy(self, ctx: LionContext, - allow_transfers: Optional[bool] = None, + allow_transfers: Optional[appcmds.Choice[int]] = None, coins_per_xp: Optional[appcmds.Range[int, 0, 2**15]] = None): t = self.bot.translator.t if not ctx.interaction: @@ -802,7 +816,7 @@ class Economy(LionCog): modified = [] if allow_transfers is not None: - setting_allow_transfers.data = allow_transfers + setting_allow_transfers.data = bool(allow_transfers.value) await setting_allow_transfers.write() modified.append(setting_allow_transfers) if coins_per_xp is not None: diff --git a/src/modules/economy/settings.py b/src/modules/economy/settings.py index 8403b9ba..4d1fb815 100644 --- a/src/modules/economy/settings.py +++ b/src/modules/economy/settings.py @@ -10,6 +10,7 @@ from settings.groups import SettingGroup from settings.data import ModelData, ListData from settings.setting_types import ChannelListSetting, IntegerSetting, BoolSetting +from meta.context import ctx_bot from meta.config import conf from meta.sharding import THIS_SHARD from meta.logger import log_wrap @@ -40,6 +41,10 @@ class EconomySettings(SettingGroup): 'guildset:coins_per_xp|long_desc', "Members will be rewarded with this many LionCoins for every 100 XP they earn." ) + _accepts = _p( + 'guildset:coins_per_xp|long_desc', + "The number of coins to reward per 100 XP." + ) # This default needs to dynamically depend on the guild mode! _default = 50 @@ -54,6 +59,11 @@ class EconomySettings(SettingGroup): "For every **100** XP they earn, members will now be given {coin}**{amount}**." )).format(amount=self.value, coin=conf.emojis.coin) + @property + def set_str(self): + bot = ctx_bot.get() + return bot.core.mention_cmd('configure economy') if bot else None + class AllowTransfers(ModelData, BoolSetting): setting_id = 'allow_transfers' @@ -64,9 +74,40 @@ class EconomySettings(SettingGroup): ) _long_desc = _p( 'guildset:allow_transfers|long_desc', - "If disabled, members will not be able to use `/sendcoins` to transfer LionCoinds." + "If disabled, members will not be able to transfer LionCoins to each other." ) _default = True _model = CoreData.Guild _column = CoreData.Guild.allow_transfers.name + + _outputs = { + True: _p('guildset:allow_transfers|outputs:true', "Enabled (Coin transfers allowed.)"), + False: _p('guildset:allow_transfers|outputs:false', "Disabled (Coin transfers not allowed.)"), + } + _outputs[None] = _outputs[_default] + + @property + def set_str(self): + bot = ctx_bot.get() + return bot.core.mention_cmd('configure economy') if bot else None + + @property + def update_message(self): + t = ctx_translator.get().t + bot = ctx_bot.get() + if self.value: + formatted = t(_p( + 'guildset:allow_transfers|set_response|set:true', + "Members will now be able to use {send_cmd} to transfer {coin}" + )) + else: + formatted = t(_p( + 'guildset:allow_transfers|set_response|set:false', + "Members will not be able to use {send_cmd} to transfer {coin}" + )) + formatted = formatted.format( + send_cmd=bot.core.mention_cmd('send'), + coin=conf.emojis.coin + ) + return formatted diff --git a/src/modules/economy/settingui.py b/src/modules/economy/settingui.py index 8876d5a1..be49cabf 100644 --- a/src/modules/economy/settingui.py +++ b/src/modules/economy/settingui.py @@ -65,7 +65,7 @@ class EconomyConfigUI(ConfigUI): class EconomyDashboard(DashboardSection): section_name = _p( 'dash:economy|title', - "Economy Configuration" + "Economy Configuration ({commands[configure economy]})" ) configui = EconomyConfigUI setting_classes = EconomyConfigUI.setting_classes diff --git a/src/modules/pomodoro/cog.py b/src/modules/pomodoro/cog.py index e47d7827..57cc5fd7 100644 --- a/src/modules/pomodoro/cog.py +++ b/src/modules/pomodoro/cog.py @@ -304,8 +304,8 @@ class TimerCog(LionCog): # ----- Timer Commands ----- @cmds.hybrid_group( - name=_p('cmd:pomodoro', "pomodoro"), - desc=_p('cmd:pomodoro|desc', "Base group for all pomodoro timer commands.") + name=_p('cmd:pomodoro', "timers"), + description=_p('cmd:pomodoro|desc', "Base group for all pomodoro timer commands.") ) @cmds.guild_only() async def pomodoro_group(self, ctx: LionContext): @@ -787,7 +787,7 @@ class TimerCog(LionCog): await timer.update_status_card() # Show the config UI - ui = TimerOptionsUI(self.bot, timer, timer_role) + ui = TimerOptionsUI(self.bot, timer, timer_role, callerid=ctx.author.id) await ui.run(ctx.interaction) await ui.wait() diff --git a/src/modules/pomodoro/settings.py b/src/modules/pomodoro/settings.py index 1ec91971..ca0cb930 100644 --- a/src/modules/pomodoro/settings.py +++ b/src/modules/pomodoro/settings.py @@ -14,6 +14,7 @@ class TimerSettings(SettingGroup): class PomodoroChannel(ModelData, ChannelSetting): setting_id = 'pomodoro_channel' _event = 'guildset_pomodoro_channel' + _set_cmd = 'configure pomodoro' _display_name = _p('guildset:pomodoro_channel', "pomodoro_channel") _desc = _p( @@ -27,6 +28,15 @@ class TimerSettings(SettingGroup): "If this setting is not set, pomodoro notifications will default to the " "timer voice channel itself." ) + _notset_str = _p( + 'guildset:pomodoro_channel|formatted|notset', + "Not Set (Will use timer voice channel.)" + ) + _accepts = _p( + 'guildset:pomodoro_channel|accepts', + "Timer notification channel name or id." + ) + _model = CoreData.Guild _column = CoreData.Guild.pomodoro_channel.name @@ -45,3 +55,12 @@ class TimerSettings(SettingGroup): "Pomodoro timer notifications will now default to their voice channel." )) return resp + + @property + def set_str(self) -> str: + cmdstr = super().set_str + t = ctx_translator.get().t + return t(_p( + 'guildset:pomdoro_channel|set_using', + "{cmd} or channel selector below." + )).format(cmd=cmdstr) diff --git a/src/modules/pomodoro/settingui.py b/src/modules/pomodoro/settingui.py index bc6982cd..3acd0c15 100644 --- a/src/modules/pomodoro/settingui.py +++ b/src/modules/pomodoro/settingui.py @@ -78,7 +78,7 @@ class TimerConfigUI(ConfigUI): class TimerDashboard(DashboardSection): section_name = _p( 'dash:pomodoro|title', - "Pomodoro Configuration" + "Pomodoro Configuration ({commands[configure pomodoro]})" ) configui = TimerConfigUI setting_classes = TimerConfigUI.setting_classes diff --git a/src/modules/ranks/settings.py b/src/modules/ranks/settings.py index b2decb5d..b98960b3 100644 --- a/src/modules/ranks/settings.py +++ b/src/modules/ranks/settings.py @@ -23,22 +23,24 @@ class RankSettings(SettingGroup): _enum = RankType _default = RankType.VOICE _outputs = { - RankType.VOICE: '`Voice`', - RankType.XP: '`Exp`', - RankType.MESSAGE: '`Messages`' + RankType.VOICE: _p('guildset:rank_type|output:voice', '`Voice`'), + RankType.XP: _p('guildset:rank_type|output:xp', '`Exp`'), + RankType.MESSAGE: _p('guildset:rank_type|output:message', '`Messages`'), } - _inputs = { - 'voice': RankType.VOICE, - 'study': RankType.VOICE, - 'text': RankType.MESSAGE, - 'message': RankType.MESSAGE, - 'messages': RankType.MESSAGE, - 'xp': RankType.XP, - 'exp': RankType.XP + _input_formatted = { + RankType.VOICE: _p('guildset:rank_type|input_format:voice', 'Voice'), + RankType.XP: _p('guildset:rank_type|input_format:xp', 'Exp'), + RankType.MESSAGE: _p('guildset:rank_type|input_format:message', 'Messages'), + } + _input_patterns = { + RankType.VOICE: _p('guildset:rank_type|input_pattern:voice', 'voice|study'), + RankType.MESSAGE: _p('guildset:rank_type|input_pattern:voice', 'text|message|messages'), + RankType.XP: _p('guildset:rank_type|input_pattern:xp', 'xp|exp|experience'), } setting_id = 'rank_type' _event = 'guildset_rank_type' + _set_cmd = 'configure ranks' _display_name = _p('guildset:rank_type', "rank_type") _desc = _p( @@ -52,6 +54,10 @@ class RankSettings(SettingGroup): "`Exp` is a measure of message activity, and " "`Message` is a simple count of messages sent." ) + _accepts = _p( + 'guildset:rank_type|accepts', + "Voice/Exp/Messages" + ) _model = CoreData.Guild _column = CoreData.Guild.rank_type.name @@ -76,6 +82,15 @@ class RankSettings(SettingGroup): )) return resp + @property + def set_str(self) -> str: + cmdstr = super().set_str + t = ctx_translator.get().t + return t(_p( + 'guildset:rank_channel|set_using', + "{cmd} or option menu below." + )).format(cmd=cmdstr) + class RankChannel(ModelData, ChannelSetting): """ Channel to send Rank notifications. @@ -83,6 +98,7 @@ class RankSettings(SettingGroup): If DMRanks is set, this will only be used when the target user has disabled DM notifications. """ setting_id = 'rank_channel' + _set_cmd = 'configure ranks' _display_name = _p('guildset:rank_channel', "rank_channel") _desc = _p( @@ -95,14 +111,44 @@ class RankSettings(SettingGroup): "If `dm_ranks` is enabled, this channel will only be used when the user has opted not to receive " "DM notifications, or is otherwise unreachable." ) + _accepts = _p( + 'guildset:rank_channel|accepts', + "Rank notification channel name or id." + ) _model = CoreData.Guild _column = CoreData.Guild.rank_channel.name + @property + def update_message(self) -> str: + t = ctx_translator.get().t + value = self.value + if value is not None: + resp = t(_p( + 'guildset:rank_channel|set_response|set', + "Rank update messages will be sent to {channel}." + )).format(channel=value.mention) + else: + resp = t(_p( + 'guildset:rank_channel|set_response|unset', + "Rank update messages will be ignored or sent via DM (if `dm_ranks` is enabled)." + )) + return resp + + @property + def set_str(self) -> str: + cmdstr = super().set_str + t = ctx_translator.get().t + return t(_p( + 'guildset:rank_channel|set_using', + "{cmd} or channel selector below." + )).format(cmd=cmdstr) + class DMRanks(ModelData, BoolSetting): """ Whether to DM rank notifications. """ setting_id = 'dm_ranks' + _set_cmd = 'configure ranks' _display_name = _p('guildset:dm_ranks', "dm_ranks") _desc = _p( @@ -114,6 +160,21 @@ class RankSettings(SettingGroup): "If enabled, congratulatory messages for rank advancement will be direct messaged to the user, " "instead of being sent to the configured `rank_channel`." ) + _default = True _model = CoreData.Guild _column = CoreData.Guild.dm_ranks.name + + @property + def update_message(self): + t = ctx_translator.get().t + if self.data: + return t(_p( + 'guildset:dm_ranks|response:true', + "I will direct message members upon rank advancement." + )) + else: + return t(_p( + 'guildset:dm_ranks|response:false', + "I will never direct message members upon rank advancement." + )) diff --git a/src/modules/ranks/ui/config.py b/src/modules/ranks/ui/config.py index c124673f..2d7a554a 100644 --- a/src/modules/ranks/ui/config.py +++ b/src/modules/ranks/ui/config.py @@ -155,7 +155,7 @@ class RankConfigUI(ConfigUI): class RankDashboard(DashboardSection): section_name = _p( 'dash:rank|title', - "Rank Configuration", + "Rank Configuration ({commands[configure ranks]})", ) configui = RankConfigUI setting_classes = RankConfigUI.setting_classes diff --git a/src/modules/rooms/settings.py b/src/modules/rooms/settings.py index c42db9a4..fe9916e5 100644 --- a/src/modules/rooms/settings.py +++ b/src/modules/rooms/settings.py @@ -15,6 +15,7 @@ class RoomSettings(SettingGroup): class Category(ModelData, ChannelSetting): setting_id = 'rooms_category' _event = 'guildset_rooms_category' + _set_cmd = 'configure rooms' _display_name = _p( 'guildset:room_category', "rooms_category" @@ -31,6 +32,10 @@ class RoomSettings(SettingGroup): "I must have permission to create new channels in this category, " "as well as to manage permissions." ) + _accepts = _p( + 'guildset:room_category|accepts', + "Private room category name or id." + ) _model = CoreData.Guild _column = CoreData.Guild.renting_category.name @@ -53,9 +58,19 @@ class RoomSettings(SettingGroup): )).format(channel=self.value.mention) return resp + @property + def set_str(self) -> str: + cmdstr = super().set_str + t = ctx_translator.get().t + return t(_p( + 'guildset:room_category|set_using', + "{cmd} or category selector below." + )).format(cmd=cmdstr) + class Rent(ModelData, IntegerSetting): setting_id = 'rooms_price' _event = 'guildset_rooms_price' + _set_cmd = 'configure rooms' _display_name = _p( 'guildset:rooms_price', "room_rent" @@ -68,6 +83,10 @@ class RoomSettings(SettingGroup): 'guildset:rooms_rent|long_desc', "Members will be charged this many LionCoins for each day they rent a private room." ) + _accepts = _p( + 'guildset:rooms_rent|accepts', + "Number of LionCoins charged per day for a private room." + ) _default = 1000 _model = CoreData.Guild @@ -88,6 +107,7 @@ class RoomSettings(SettingGroup): class MemberLimit(ModelData, IntegerSetting): setting_id = 'rooms_slots' _event = 'guildset_rooms_slots' + _set_cmd = 'configure rooms' _display_name = _p('guildset:rooms_slots', "room_member_cap") _desc = _p( @@ -100,6 +120,10 @@ class RoomSettings(SettingGroup): "or through the `/room invite` command. " "This setting limits the maximum number of members a private room may hold." ) + _accepts = _p( + 'guildset:rooms_slots|accepts', + "Maximum number of members allowed per private room." + ) _default = 25 _model = CoreData.Guild @@ -117,6 +141,7 @@ class RoomSettings(SettingGroup): class Visible(ModelData, BoolSetting): setting_id = 'rooms_visible' _event = 'guildset_rooms_visible' + _set_cmd = 'configure rooms' _display_name = _p('guildset:rooms_visible', "room_visibility") _desc = _p( @@ -129,6 +154,21 @@ class RoomSettings(SettingGroup): "enabled for the `@everyone` role." ) _default = False + _accepts = _p('guildset:rooms_visible|accepts', "Visible/Invisible") + _outputs = { + True: _p('guildset:rooms_visible|output:true', "Visible"), + False: _p('guildset:rooms_visible|output:false', "Invisible"), + } + _outputs[None] = _outputs[_default] + + _truthy = _p( + 'guildset:rooms_visible|parse:truthy_values', + "visible|enabled|yes|true|on|enable|1" + ) + _falsey = _p( + 'guildset:rooms_visible|parse:falsey_values', + 'invisible|disabled|no|false|off|disable|0' + ) _model = CoreData.Guild _column = CoreData.Guild.renting_visible.name @@ -148,6 +188,15 @@ class RoomSettings(SettingGroup): )) return resp + @property + def set_str(self) -> str: + cmdstr = super().set_str + t = ctx_translator.get().t + return t(_p( + 'guildset:rooms_visible|set_using', + "{cmd} or toggle below." + )).format(cmd=cmdstr) + model_settings = ( Category, Rent, diff --git a/src/modules/rooms/settingui.py b/src/modules/rooms/settingui.py index 9ea516f0..7ae90d5a 100644 --- a/src/modules/rooms/settingui.py +++ b/src/modules/rooms/settingui.py @@ -95,7 +95,7 @@ class RoomSettingUI(ConfigUI): class RoomDashboard(DashboardSection): section_name = _p( 'dash:rooms|title', - "Private Room Configuration" + "Private Room Configuration ({commands[configure rooms]})" ) configui = RoomSettingUI setting_classes = RoomSettingUI.setting_classes diff --git a/src/modules/statistics/settings.py b/src/modules/statistics/settings.py index 440805c2..9d01bd73 100644 --- a/src/modules/statistics/settings.py +++ b/src/modules/statistics/settings.py @@ -14,7 +14,8 @@ from settings.groups import SettingGroup from meta import conf, LionBot from meta.context import ctx_bot -from utils.lib import tabulate +from meta.errors import UserInputError +from utils.lib import tabulate, utc_now from utils.ui import ConfigUI, FastModal, error_handler_for, ModalRetryUI from utils.lib import MessageArgs from core.data import CoreData @@ -33,16 +34,24 @@ class StatTypeSetting(EnumSetting): """ _enum = StatisticType _outputs = { - StatisticType.VOICE: '`Voice`', - StatisticType.TEXT: '`Text`', - StatisticType.ANKI: '`Anki`' + StatisticType.VOICE: _p('settype:stat|output:voice', "`Voice`"), + StatisticType.TEXT: _p('settype:stat|output:text', "`Text`"), + StatisticType.ANKI: _p('settype:stat|output:anki', "`Anki`"), } - _inputs = { - 'voice': StatisticType.VOICE, - 'study': StatisticType.VOICE, - 'text': StatisticType.TEXT, - 'anki': StatisticType.ANKI + _input_formatted = { + StatisticType.VOICE: _p('settype:stat|input_format:voice', "Voice"), + StatisticType.TEXT: _p('settype:stat|input_format:text', "Text"), + StatisticType.ANKI: _p('settype:stat|input_format:anki', "Anki"), } + _input_patterns = { + StatisticType.VOICE: _p('settype:stat|input_pattern:voice', "voice|study"), + StatisticType.TEXT: _p('settype:stat|input_pattern:text', "text|messages"), + StatisticType.ANKI: _p('settype:stat|input_pattern:anki', "anki"), + } + _accepts = _p( + 'settype:state|accepts', + 'Voice/Text/Anki' + ) class StatisticsSettings(SettingGroup): @@ -74,6 +83,7 @@ class StatisticsSettings(SettingGroup): Time is assumed to be in set guild timezone (although supports +00 syntax) """ setting_id = 'season_start' + _set_cmd = 'configure statistics' _display_name = _p('guildset:season_start', "season_start") _desc = _p( @@ -86,11 +96,17 @@ class StatisticsSettings(SettingGroup): "and the leaderboard will display activity since this time by default. " "Unset to disable seasons and use all-time statistics instead." ) + _accepts = _p( + 'guildset:season_start|accepts', + "The season start time in the form YYYY-MM-DD HH:MM" + ) + _notset_str = _p( + 'guildset:season_start|notset', + "Not Set (Using all-time statistics)" + ) _model = CoreData.Guild _column = CoreData.Guild.season_start.name - # TODO: Offer to update badge ranks when this changes? - # TODO: Don't allow future times? @classmethod async def _timezone_from_id(cls, guildid, **kwargs): @@ -98,6 +114,38 @@ class StatisticsSettings(SettingGroup): lguild = await bot.core.lions.fetch_guild(guildid) return lguild.timezone + @classmethod + async def _parse_string(cls, parent_id, string, **kwargs): + parsed = await super()._parse_string(parent_id, string, **kwargs) + if parsed is not None and parsed > utc_now(): + t = ctx_translator.get().t + raise UserInputError(t(_p( + 'guildset:season_start|parse|error:future_time', + "Provided season start time {timestamp} is in the future!" + )).format(timestamp=f"")) + + @property + def update_message(self) -> str: + t = ctx_translator.get().t + bot = ctx_bot.get() + value = self.value + if value is not None: + resp = t(_p( + 'guildset:season_start|set_response|set', + "The leaderboard season and activity ranks will now count from {timestamp}. " + "Member ranks will update when they are next active. Use {rank_cmd} to refresh immediately." + )).format( + timestamp=self.formatted, + rank_cmd=bot.core.mention_cmd('ranks') + ) + else: + resp = t(_p( + 'guildset:season_start|set_response|unset', + "The leaderboard and activity ranks will now count all-time statistics. " + "Member ranks will update when they are next active. Use {rank_cmd} to refresh immediately." + )).format(rank_cmd=bot.core.mention_cmd('ranks')) + return resp + class UnrankedRoles(ListData, RoleListSetting): """ List of roles not displayed on the leaderboard @@ -113,6 +161,10 @@ class StatisticsSettings(SettingGroup): 'guildset:unranked_roles|long_desc', "When set, members with *any* of these roles will not appear on the /leaderboard ranking list." ) + _accepts = _p( + 'guildset:unranked_roles|accepts', + "Comma separated list of unranked role names or ids." + ) _default = None _table_interface = StatsData.unranked_roles @@ -124,7 +176,29 @@ class StatisticsSettings(SettingGroup): @property def set_str(self): - return "Role selector below" + t = ctx_translator.get().t + return t(_p( + 'guildset:unranked_roles|set_using', + "Role selector below." + )) + + @property + def update_message(self) -> str: + t = ctx_translator.get().t + value = self.value + if value is not None: + resp = t(_p( + 'guildset:unranked_roles|set_response|set', + "Members of the following roles will not appear on the leaderboard: {roles}" + )).format( + roles=self.formatted + ) + else: + resp = t(_p( + 'guildset:unranked_roles|set_response|unset', + "You have cleared the unranked role list." + )) + return resp class VisibleStats(ListData, ListSetting, InteractiveSetting): """ @@ -145,6 +219,10 @@ class StatisticsSettings(SettingGroup): 'guildset:visible_stats|desc', "Choose which statistics types to display in the leaderboard and statistics commands." ) + _accepts = _p( + 'guildset:visible_stats|accepts', + "Voice, Text, Anki" + ) # TODO: Format VOICE as STUDY when possible? _default = [ @@ -159,6 +237,23 @@ class StatisticsSettings(SettingGroup): _cache = {} + @property + def set_str(self): + t = ctx_translator.get().t + return t(_p( + 'guildset:visible_stats|set_using', + "Option menu below." + )) + + @property + def update_message(self) -> str: + t = ctx_translator.get().t + resp = t(_p( + 'guildset:visible_stats|set_response', + "Members will be able to view the following statistics types: {types}" + )).format(types=self.formatted) + return resp + class DefaultStat(ModelData, StatTypeSetting): """ Which of the three stats to display by default diff --git a/src/modules/sysadmin/__init__.py b/src/modules/sysadmin/__init__.py index 8a27d2da..21c38bfc 100644 --- a/src/modules/sysadmin/__init__.py +++ b/src/modules/sysadmin/__init__.py @@ -1,12 +1,14 @@ -from .exec_cog import Exec -from .blacklists import Blacklists -from .guild_log import GuildLog -from .presence import PresenceCtrl - -from .dash import LeoSettings +from babel.translator import LocalBabel +babel = LocalBabel('sysadmin') async def setup(bot): + from .exec_cog import Exec + from .blacklists import Blacklists + from .guild_log import GuildLog + from .presence import PresenceCtrl + + from .dash import LeoSettings await bot.add_cog(LeoSettings(bot)) await bot.add_cog(Blacklists(bot)) diff --git a/src/modules/sysadmin/presence.py b/src/modules/sysadmin/presence.py index 033cb8c6..70ddf17c 100644 --- a/src/modules/sysadmin/presence.py +++ b/src/modules/sysadmin/presence.py @@ -23,6 +23,10 @@ from settings.groups import SettingGroup from wards import sys_admin +from . import babel + +_p = babel._p + logger = logging.getLogger(__name__) @@ -104,49 +108,93 @@ class PresenceSettings(SettingGroup): """ Control the bot status and activity. """ - _title = "Presence Settings ({bot.core.cmd_name_cache[presence].mention})" + _title = "Presence Settings ({bot.core.mention_cache[presence]})" class PresenceStatus(ModelData, EnumSetting[str, AppStatus]): setting_id = 'presence_status' - display_name = 'online_status' - desc = "Bot status indicator" - long_desc = "Whether the bot account displays as online, idle, dnd, or offline." - accepts = "One of 'online', 'idle', 'dnd', or 'offline'." + _display_name = _p('botset:presence_status', 'online_status') + _desc = _p('botset:presence_status|desc', "Bot status indicator") + _long_desc = _p( + 'botset:presence_status|long_desc', + "Whether the bot account displays as online, idle, dnd, or offline." + ) + _accepts = _p( + 'botset:presence_status|accepts', + "Online/Idle/Dnd/Offline" + ) _model = PresenceData.AppPresence _column = PresenceData.AppPresence.online_status.name _create_row = True _enum = AppStatus - _outputs = {item: item.value[1] for item in _enum} - _inputs = {item.name: item for item in _enum} + _outputs = { + AppStatus.online: _p('botset:presence_status|output:online', "**Online**"), + AppStatus.idle: _p('botset:presence_status|output:idle', "**Idle**"), + AppStatus.dnd: _p('botset:presence_status|output:dnd', "**Do Not Disturb**"), + AppStatus.offline: _p('botset:presence_status|output:offline', "**Offline**"), + } + _input_formatted = { + AppStatus.online: _p('botset:presence_status|input_format:online', "Online"), + AppStatus.idle: _p('botset:presence_status|input_format:idle', "Idle"), + AppStatus.dnd: _p('botset:presence_status|input_format:dnd', "DND"), + AppStatus.offline: _p('botset:presence_status|input_format:offline', "Offline"), + } + _input_patterns = { + AppStatus.online: _p('botset:presence_status|input_pattern:online', "on|online"), + AppStatus.idle: _p('botset:presence_status|input_pattern:idle', "idle"), + AppStatus.dnd: _p('botset:presence_status|input_pattern:dnd', "do not disturb|dnd"), + AppStatus.offline: _p('botset:presence_status|input_pattern:offline', "off|offline|invisible"), + } _default = AppStatus.online class PresenceType(ModelData, EnumSetting[str, AppActivityType]): setting_id = 'presence_type' - display_name = 'activity_type' - desc = "Type of presence activity" - long_desc = "Whether the bot activity is shown as 'Listening', 'Playing', or 'Watching'." - accepts = "One of 'listening', 'playing', 'watching', or 'streaming'." + _display_name = _p('botset:presence_type', 'activity_type') + _desc = _p('botset:presence_type|desc', "Type of presence activity") + _long_desc = _p( + 'botset:presence_type|long_desc', + "Whether the bot activity is shown as 'Listening', 'Playing', or 'Watching'." + ) + _accepts = _p( + 'botset:presence_type|accepts', + "Listening/Playing/Watching/Streaming" + ) _model = PresenceData.AppPresence _column = PresenceData.AppPresence.activity_type.name _create_row = True _enum = AppActivityType - _outputs = {item: item.value[1] for item in _enum} - _inputs = {item.name: item for item in _enum} + _outputs = { + AppActivityType.watching: _p('botset:presence_type|output:watching', "**Watching**"), + AppActivityType.listening: _p('botset:presence_type|output:listening', "**Listening**"), + AppActivityType.playing: _p('botset:presence_type|output:playing', "**Playing**"), + AppActivityType.streaming: _p('botset:presence_type|output:streaming', "**Streaming**"), + } + _input_formats = { + AppActivityType.watching: _p('botset:presence_type|input_format:watching', "Watching"), + AppActivityType.listening: _p('botset:presence_type|input_format:listening', "Listening"), + AppActivityType.playing: _p('botset:presence_type|input_format:playing', "Playing"), + AppActivityType.streaming: _p('botset:presence_type|input_format:streaming', "Streaming"), + } + _input_patterns = { + AppActivityType.watching: _p('botset:presence_type|input_pattern:watching', "watching"), + AppActivityType.listening: _p('botset:presence_type|input_pattern:listening', "listening"), + AppActivityType.playing: _p('botset:presence_type|input_pattern:playing', "playing"), + AppActivityType.streaming: _p('botset:presence_type|input_pattern:streaming', "streaming"), + } _default = AppActivityType.watching class PresenceName(ModelData, StringSetting[str]): setting_id = 'presence_name' - display_name = 'activity_name' - desc = "Name of the presence activity" - long_desc = "Presence activity name." - accepts = "Any string." + _display_name = _p('botset:presence_name', 'activity_name') + _desc = _p("botset:presence_name|desc", "Name of the presence activity") + _long_desc = _p("botset:presence_name|long_desc", "Presence activity name.") + _accepts = _p('botset:presence_name|accepts', "The name of the activity to show.") _model = PresenceData.AppPresence _column = PresenceData.AppPresence.activity_name.name diff --git a/src/modules/tasklist/settings.py b/src/modules/tasklist/settings.py index 83e75f09..a4933cf9 100644 --- a/src/modules/tasklist/settings.py +++ b/src/modules/tasklist/settings.py @@ -8,7 +8,7 @@ from settings import ListData, ModelData from settings.setting_types import StringSetting, BoolSetting, ChannelListSetting, IntegerSetting from settings.groups import SettingGroup -from meta import conf, LionBot +from meta import conf, LionBot, ctx_bot from utils.lib import tabulate from utils.ui import LeoUI, FastModal, error_handler_for, ModalRetryUI, DashboardSection from core.data import CoreData @@ -28,6 +28,7 @@ class TasklistSettings(SettingGroup): Exposed via `/configure tasklist`, and the standard configuration interface. """ setting_id = 'task_reward' + _set_cmd = 'configure tasklist' _display_name = _p('guildset:task_reward', "task_reward") _desc = _p( @@ -38,6 +39,10 @@ class TasklistSettings(SettingGroup): 'guildset:task_reward|long_desc', "The number of coins members will be rewarded each time they complete a task on their tasklist." ) + _accepts = _p( + 'guildset:task_reward|accepts', + "The number of LionCoins to reward per task." + ) _default = 50 _model = CoreData.Guild @@ -51,20 +56,19 @@ class TasklistSettings(SettingGroup): "Members will now be rewarded {coin}**{amount}** for each completed task." )).format(coin=conf.emojis.coin, amount=self.data) - @property - def set_str(self): - return '' - @classmethod def _format_data(cls, parent_id, data, **kwargs): if data is not None: - return "{coin}**{amount}** per task.".format( - coin=conf.emojis.coin, - amount=data - ) + t = ctx_translator.get().t + formatted = t(_p( + 'guildset:task_reward|formatted', + "{coin}**{amount}** per task." + )).format(coin=conf.emojis.coin, amount=data) + return formatted class task_reward_limit(ModelData, IntegerSetting): setting_id = 'task_reward_limit' + _set_cmd = 'configure tasklist' _display_name = _p('guildset:task_reward_limit', "task_reward_limit") _desc = _p( @@ -76,6 +80,10 @@ class TasklistSettings(SettingGroup): "Maximum number of times in each 24h period that members will be rewarded " "for completing a task." ) + _accepts = _p( + 'guildset:task_reward_limit|accepts', + "The maximum number of tasks to reward LC for per 24h." + ) _default = 10 _model = CoreData.Guild @@ -89,16 +97,15 @@ class TasklistSettings(SettingGroup): "Members will now be rewarded for task completion at most **{amount}** times per 24h." )).format(amount=self.data) - @property - def set_str(self): - return '' - @classmethod def _format_data(cls, parent_id, data, **kwargs): if data is not None: - return "`{number}` per 24 hours.".format( - number=data - ) + t = ctx_translator.get().t + formatted = t(_p( + 'guildset:task_reward_limit|formatted', + "`{number}` per 24 hours." + )).format(number=data) + return formatted class tasklist_channels(ListData, ChannelListSetting): setting_id = 'tasklist_channels' @@ -113,6 +120,10 @@ class TasklistSettings(SettingGroup): "If set, members will only be able to open their tasklist in these channels.\n" "If a category is selected, this will allow all channels under that category." ) + _accepts = _p( + 'guildset:tasklist_channels|accepts', + "Comma separated list of tasklist channel names or ids." + ) _default = None _table_interface = TasklistData.channels @@ -122,14 +133,32 @@ class TasklistSettings(SettingGroup): _cache = {} + @property + def update_message(self): + t = ctx_translator.get().t + if self.data: + resp = t(_p( + 'guildset:tasklist_channels|set_response|set', + "Members may now open their tasklist in the following channels: {channels}" + )).format(channels=self.formatted) + else: + resp = t(_p( + 'guildset:tasklist_channels|set_response|unset', + "Members may now open their tasklist in any channel." + )) + return resp + @property def set_str(self): - return "Channel selector below." + t = ctx_translator.get().t + return t(_p( + 'guildset:tasklist_channels|set_using', + "Channel selector below." + )) class TasklistConfigUI(LeoUI): - # TODO: Back option to global guild config - # TODO: Cohesive edit + # TODO: Migrate to ConfigUI _listening = {} setting_classes = ( TasklistSettings.task_reward, @@ -286,6 +315,6 @@ class TasklistConfigUI(LeoUI): class TasklistDashboard(DashboardSection): - section_name = _p('dash:tasklist|name', "Tasklist Configuration") + section_name = _p('dash:tasklist|name', "Tasklist Configuration ({commands[configure tasklist]})") configui = TasklistConfigUI setting_classes = configui.setting_classes diff --git a/src/modules/user_config/cog.py b/src/modules/user_config/cog.py index 4027034b..4f87e4e7 100644 --- a/src/modules/user_config/cog.py +++ b/src/modules/user_config/cog.py @@ -33,6 +33,7 @@ class UserConfigSettings(SettingGroup): and several other components such as reminder times. """ setting_id = 'timezone' + _set_cmd = 'my timezone' _display_name = _p('userset:timezone', "timezone") _desc = _p( diff --git a/src/settings/setting_types.py b/src/settings/setting_types.py index 49479533..f38cbe8c 100644 --- a/src/settings/setting_types.py +++ b/src/settings/setting_types.py @@ -14,7 +14,7 @@ from dateutil.parser import parse, ParserError from meta.context import ctx_bot from meta.errors import UserInputError from utils.lib import strfdur, parse_duration -from babel import ctx_translator +from babel.translator import ctx_translator, LazyStr from .base import ParentID from .ui import InteractiveSetting, SettingWidget @@ -45,7 +45,7 @@ class StringSetting(InteractiveSetting[ParentID, str, str]): Default: True """ - _accepts = _p('settype:string|accepts', "Any text") + _accepts = _p('settype:string|accepts', "Any Text") _maxlen: int = 4000 _quote: bool = True @@ -123,7 +123,7 @@ class ChannelSetting(Generic[ParentID, CT], InteractiveSetting[ParentID, int, CT List of guild channel types to accept. Default: [] """ - _accepts = _p('settype:channel|accepts', "Enter a channel name or id") + _accepts = _p('settype:channel|accepts', "A channel name or id") _selector_placeholder = "Select a Channel" channel_types: list[discord.ChannelType] = [] @@ -151,8 +151,26 @@ class ChannelSetting(Generic[ParentID, CT], InteractiveSetting[ParentID, int, CT @classmethod async def _parse_string(cls, parent_id, string: str, **kwargs): - # TODO: Waiting on seeker utils. - ... + if not string or string.lower() == 'none': + return None + + t = ctx_translator.get().t + bot = ctx_bot.get() + channel = None + guild = bot.get_guild(parent_id) + + if string.isdigit(): + maybe_id = int(string) + channel = guild.get_channel(maybe_id) + else: + channel = next((channel for channel in guild.channels if channel.name.lower() == string.lower()), None) + + if channel is None: + raise UserInputError(t(_p( + 'settype:channel|parse|error:not_found', + "Channel `{string}` could not be found in this guild!".format(string=string) + ))) + return channel.id @classmethod def _format_data(cls, parent_id, data, **kwargs): @@ -161,25 +179,11 @@ class ChannelSetting(Generic[ParentID, CT], InteractiveSetting[ParentID, int, CT """ if data: return "<#{}>".format(data) - else: - return "Not Set" @property def input_formatted(self) -> str: - """ - Returns the channel name if possible, otherwise the id. - """ - if self._data is not None: - channel = self.value - if channel is not None: - if isinstance(channel, discord.Object): - return str(channel.id) - else: - return f"#{channel.name}" - else: - return "" - else: - return "" + data = self._data + return str(data) if data else '' class Widget(SettingWidget['ChannelSetting']): def update_children(self): @@ -236,7 +240,7 @@ class MessageablelSetting(ChannelSetting): bot = ctx_bot.get() channel = bot.get_channel(data) if channel is None: - channel = ctx.bot.get_partial_messageable(data, guild_id=parent_id) + channel = bot.get_partial_messageable(data, guild_id=parent_id) return channel @@ -250,7 +254,7 @@ class RoleSetting(InteractiveSetting[ParentID, int, Union[discord.Role, discord. Placeholder to use in the Widget selector. Default: "Select a Role" """ - _accepts = _p('settype:role|accepts', "Enter a role name or id") + _accepts = _p('settype:role|accepts', "A role name or id") _selector_placeholder = "Select a Role" @@ -291,8 +295,26 @@ class RoleSetting(InteractiveSetting[ParentID, int, Union[discord.Role, discord. @classmethod async def _parse_string(cls, parent_id, string: str, **kwargs): - # TODO: Waiting on seeker utils. - ... + if not string or string.lower() == 'none': + return None + + t = ctx_translator.get().t + bot = ctx_bot.get() + role = None + guild = bot.get_guild(parent_id) + + if string.isdigit(): + maybe_id = int(string) + role = guild.get_role(maybe_id) + else: + role = next((role for role in guild.roles if role.name.lower() == string.lower()), None) + + if role is None: + raise UserInputError(t(_p( + 'settype:role|parse|error:not_found', + "Role `{string}` could not be found in this guild!".format(string=string) + ))) + return role.id @classmethod def _format_data(cls, parent_id, data, **kwargs): @@ -306,20 +328,8 @@ class RoleSetting(InteractiveSetting[ParentID, int, Union[discord.Role, discord. @property def input_formatted(self) -> str: - """ - Returns the role name if possible, otherwise the id. - """ - if self._data is not None: - role = self.value - if role is not None: - if isinstance(role, discord.Object): - return str(role.id) - else: - return f"@{role.name}" - else: - return "" - else: - return "" + data = self._data + return str(data) if data else '' class Widget(SettingWidget['RoleSetting']): def update_children(self): @@ -367,33 +377,54 @@ class BoolSetting(InteractiveSetting[ParentID, bool, bool]): Default: {True: "On", False: "Off", None: "Not Set"} """ - _accepts = _p('settype:bool|accepts', "True/False") + _accepts = _p('settype:bool|accepts', "Enabled/Disabled") # Values that are accepted as truthy and falsey by the parser - _truthy = {"yes", "true", "on", "enable", "enabled"} - _falsey = {"no", "false", "off", "disable", "disabled"} + _truthy = _p( + 'settype:bool|parse:truthy_values', + "enabled|yes|true|on|enable|1" + ) + _falsey = _p( + 'settype:bool|parse:falsey_values', + 'disabled|no|false|off|disable|0' + ) # The user-friendly output strings to use for each value - _outputs = {True: "On", False: "Off", None: "Not Set"} + _outputs = { + True: _p('settype:bool|output:true', "On"), + False: _p('settype:bool|output:false', "Off"), + None: _p('settype:bool|output:none', "Not Set"), + } # Button labels _true_button_args: dict[str, Any] = {} _false_button_args: dict[str, Any] = {} _reset_button_args: dict[str, Any] = {} + @classmethod + def truthy_values(cls) -> set: + t = ctx_translator.get().t + return t(cls._truthy).lower().split('|') + + @classmethod + def falsey_values(cls) -> set: + t = ctx_translator.get().t + return t(cls._falsey).lower().split('|') + @property def input_formatted(self) -> str: """ Return the current data string. """ if self._data is not None: - output = self._outputs[self._data] - set = (self._falsey, self._truthy)[self._data] + t = ctx_translator.get().t + output = t(self._outputs[self._data]) + input_set = self.truthy_values() if self._data else self.falsey_values() - if output.lower() in set: + if output.lower() in input_set: return output else: - return next(iter(set)) + return next(iter(input_set)) else: return "" @@ -419,9 +450,9 @@ class BoolSetting(InteractiveSetting[ParentID, bool, bool]): _userstr = string.lower() if not _userstr or _userstr == "none": return None - if _userstr in cls._truthy: + if _userstr in cls.truthy_values(): return True - elif _userstr in cls._falsey: + elif _userstr in cls.falsey_values(): return False else: raise UserInputError("Could not parse `{}` as a boolean.".format(string)) @@ -431,7 +462,8 @@ class BoolSetting(InteractiveSetting[ParentID, bool, bool]): """ Use provided _outputs dictionary to format data. """ - return cls._outputs[data] + t = ctx_translator.get().t + return t(cls._outputs[data]) class Widget(SettingWidget['BoolSetting']): def update_children(self): @@ -676,8 +708,7 @@ class TimezoneSetting(InteractiveSetting[ParentID, str, TZT]): # TODO Definitely need autocomplete here _accepts = _p( 'settype:timezone|accepts', - "A timezone name from [this list](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) " - "(e.g. `Europe/London`)." + "A timezone name from the 'tz database' (e.g. 'Europe/London')" ) @property @@ -739,6 +770,23 @@ class TimezoneSetting(InteractiveSetting[ParentID, str, TZT]): ) from None return str(timezone) + def _desc_table(self) -> list[str]: + translator = ctx_translator.get() + t = translator.t + + lines = super()._desc_table() + lines.append(( + t(_p( + 'settype:timezone|summary_table|field:supported|key', + "Supported" + )), + t(_p( + 'settype:timezone|summary_table|field:supported|value', + "Any timezone from the [tz database]({link})." + )).format(link="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones") + )) + return lines + @classmethod async def parse_acmpl(cls, interaction: discord.Interaction, partial: str): bot = interaction.client @@ -794,7 +842,7 @@ class TimestampSetting(InteractiveSetting[ParentID, str, dt.datetime]): """ _accepts = _p( 'settype:timestamp|accepts', - "A timestamp in the form yyyy-mm-dd HH:MM" + "A timestamp in the form YYYY-MM-DD HH:MM" ) @classmethod @@ -812,22 +860,24 @@ class TimestampSetting(InteractiveSetting[ParentID, str, dt.datetime]): ts = None else: local_tz = await cls._timezone_from_id(parent_id, **kwargs) - default = dt.datetime.now(tz=local_tz).replace( + now = dt.datetime.now(tz=local_tz) + default = now.replace( hour=0, minute=0, second=0, microsecond=0 ) try: ts = parse(string, fuzzy=True, default=default) except ParserError: - # TOLOCALISE: - raise UserInputError("Invalid date string passed") + t = ctx_translator.get().t + raise UserInputError(t(_p( + 'settype:timestamp|parse|error:invalid', + "Could not parse `{provided}` as a timestamp. Please use `YYYY-MM-DD HH:MM` format." + ))) return ts @classmethod def _format_data(cls, parent_id: ParentID, data, **kwargs): - if data is None: - return "Not Set" - else: + if data is not None: return "".format(int(data.timestamp())) @classmethod @@ -839,6 +889,41 @@ class TimestampSetting(InteractiveSetting[ParentID, str, dt.datetime]): """ return pytz.UTC + @property + def input_formatted(self) -> str: + if self._data: + formatted = self._data.strftime('%Y-%M-%d %H:%M') + else: + formatted = '' + return formatted + + +class RawSetting(InteractiveSetting[ParentID, Any, Any]): + """ + Basic implementation of an interactive setting with identical value and data type. + """ + _accepts = _p('settype:raw|accepts', "Anything") + + @property + def input_formatted(self) -> str: + return str(self._data) if self._data is not None else '' + + @classmethod + def _data_from_value(cls, parent_id, value, **kwargs): + return value + + @classmethod + def _data_to_value(cls, parent_id, data, **kwargs): + return data + + @classmethod + async def _parse_string(cls, parent_id: ParentID, string: str, **kwargs): + return string + + @classmethod + def _format_data(cls, parent_id: ParentID, data, **kwargs): + return str(data) if data is not None else None + ET = TypeVar('ET', bound='Enum') @@ -866,8 +951,9 @@ class EnumSetting(InteractiveSetting[ParentID, ET, ET]): """ _enum: Type[ET] - _outputs: dict[ET, str] - _inputs: dict[str, ET] + _outputs: dict[ET, LazyStr] + _input_patterns: dict[ET: LazyStr] + _input_formatted: dict[ET: LazyStr] _accepts = _p('settype:enum|accepts', "A valid option.") @@ -877,8 +963,9 @@ class EnumSetting(InteractiveSetting[ParentID, ET, ET]): Return the output string for the current data. This assumes the output strings are accepted as inputs! """ + t = ctx_translator.get().t if self._data is not None: - return self._outputs[self._data] + return t(self._input_formatted[self._data]) else: return "" @@ -901,23 +988,39 @@ class EnumSetting(InteractiveSetting[ParentID, ET, ET]): """ Parse the user input into an enum item. """ - # TODO: Another selection case. if not string: return None + string = string.lower() - if string not in cls._inputs: - raise UserInputError("Invalid choice!") - return cls._inputs[string] + t = ctx_translator.get().t + + found = None + for enumitem, pattern in cls._input_patterns.items(): + item_keys = set(t(pattern).lower().split('|')) + if string in item_keys: + found = enumitem + break + + if not found: + raise UserInputError( + t(_p( + 'settype:enum|parse|error:not_found', + "`{provided}` is not a valid option!" + )).format(provided=string) + ) + + return found @classmethod def _format_data(cls, parent_id: ParentID, data, **kwargs): """ Format the enum using the provided output map. """ + t = ctx_translator.get().t if data is not None: if data not in cls._outputs: raise ValueError(f"Enum item {data} unmapped.") - return cls._outputs[data] + return t(cls._outputs[data]) class DurationSetting(InteractiveSetting[ParentID, int, int]): @@ -1110,9 +1213,7 @@ class ListSetting: """ Format the list by adding `,` between each formatted item """ - if not data: - return 'Not Set' - else: + if data: formatted_items = [] for item in data: formatted_item = cls._setting._format_data(id, item) @@ -1142,8 +1243,7 @@ class ChannelListSetting(ListSetting, InteractiveSetting): """ _accepts = _p( 'settype:channel_list|accepts', - "Comma separated list of channel mentions/ids/names. Use `None` to unset. " - "Write `--add` or `--remove` to add or remove channels." + "Comma separated list of channel ids." ) _setting = ChannelSetting @@ -1154,8 +1254,7 @@ class RoleListSetting(ListSetting, InteractiveSetting): """ _accepts = _p( 'settype:role_list|accepts', - "Comma separated list of role mentions/ids/names. Use `None` to unset. " - "Write `--add` or `--remove` to add or remove roles." + 'Comma separated list of role ids.' ) _setting = RoleSetting @@ -1171,8 +1270,7 @@ class StringListSetting(InteractiveSetting, ListSetting): """ _accepts = _p( 'settype:stringlist|accepts', - "Comma separated list of strings. Use `None` to unset. " - "Write `--add` or `--remove` to add or remove strings." + 'Comma separated strings.' ) _setting = StringSetting @@ -1183,9 +1281,7 @@ class GuildIDListSetting(InteractiveSetting, ListSetting): """ _accepts = _p( 'settype:guildidlist|accepts', - "Comma separated list of guild ids. Use `None` to unset. " - "Write `--add` or `--remove` to add or remove ids. " - "The provided ids are not verified in any way." + 'Comma separated list of guild ids.' ) _setting = GuildIDSetting diff --git a/src/settings/ui.py b/src/settings/ui.py index 6d26aa18..b14b596c 100644 --- a/src/settings/ui.py +++ b/src/settings/ui.py @@ -15,6 +15,9 @@ from meta.context import ctx_bot from babel.translator import ctx_translator, LazyStr from .base import BaseSetting, ParentID, SettingData, SettingValue +from . import babel + +_p = babel._p ST = TypeVar('ST', bound='InteractiveSetting') @@ -172,6 +175,8 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]): _desc: LazyStr # User readable brief description of the setting _long_desc: LazyStr # User readable long description of the setting _accepts: LazyStr # User readable description of the acceptable values + _set_cmd: str = None + _notset_str: LazyStr = _p('setting|formatted|notset', "Not Set") _virtual: bool = False # Whether the setting should be hidden from tables and dashboards _required: bool = False @@ -305,29 +310,61 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]): @property def set_str(self): - return None + if self._set_cmd is not None: + bot = ctx_bot.get() + if bot: + return bot.core.mention_cmd(self._set_cmd) + else: + return f"`/{self._set_cmd}`" + + @property + def notset_str(self): + t = ctx_translator.get().t + return t(self._notset_str) @property def embed(self): """ Returns a full embed describing this setting. """ + t = ctx_translator.get().t embed = discord.Embed( - title="Configuration options for `{}`".format(self.display_name), + title=t(_p( + 'setting|summary_embed|title', + "Configuration options for `{name}`" + )).format(name=self.display_name), ) embed.description = "{}\n{}".format(self.long_desc.format(self=self), self.desc_table) return embed - @property - def desc_table(self): + def _desc_table(self) -> list[str]: + t = ctx_translator.get().t lines = [] - lines.append(('Currently', self.formatted or "Not Set")) - if (default := self.default) is not None: - lines.append(('By Default', self._format_data(self.parent_id, default) or "No Default")) - if (set_str := self.set_str) is not None: - lines.append(('Set Using', set_str)) - return '\n'.join(tabulate(*lines)) + # Currently line + lines.append(( + t(_p('setting|summary_table|field:currently|key', "Currently")), + self.formatted or self.notset_str + )) + + # Default line + if (default := self.default) is not None: + lines.append(( + t(_p('setting|summary_table|field:default|key', "By Default")), + self._format_data(self.parent_id, default) or 'None' + )) + + # Set using line + if (set_str := self.set_str) is not None: + lines.append(( + t(_p('setting|summary_table|field:set|key', "Set Using")), + set_str + )) + return lines + + @property + def desc_table(self) -> str: + return '\n'.join(tabulate(*self._desc_table())) @property def input_field(self) -> TextInput: @@ -366,7 +403,7 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]): Default user-readable form of the setting. Should be a short single line. """ - return self._format_data(self.parent_id, self.data, **self.kwargs) + return self._format_data(self.parent_id, self.data, **self.kwargs) or self.notset_str @property def input_formatted(self) -> str: diff --git a/src/tracking/text/settings.py b/src/tracking/text/settings.py index 8379a5d2..f25b2773 100644 --- a/src/tracking/text/settings.py +++ b/src/tracking/text/settings.py @@ -28,6 +28,7 @@ class TextTrackerSettings(SettingGroup): """ class XPPerPeriod(ModelData, IntegerSetting): setting_id = 'xp_per_period' + _set_cmd = 'configure message_exp' _display_name = _p('guildset:xp_per_period', "xp_per_5min") _desc = _p( @@ -39,6 +40,10 @@ class TextTrackerSettings(SettingGroup): "Amount of message XP to give members for each 5 minute period in which they are active (send a message). " "Note that this XP is only given *once* per period." ) + _accepts = _p( + 'guildset:xp_per_period|accepts', + "Number of message XP to reward per 5 minute active period." + ) _default = 101 # TODO: Make a dynamic default based on the global setting? _model = CoreData.Guild @@ -55,6 +60,7 @@ class TextTrackerSettings(SettingGroup): class WordXP(ModelData, IntegerSetting): setting_id = 'word_xp' + _set_cmd = 'configure message_exp' _display_name = _p('guildset:word_xp', "xp_per_100words") _desc = _p( @@ -66,6 +72,10 @@ class TextTrackerSettings(SettingGroup): "Amount of message XP to be given (additionally to the XP per period) for each hundred words. " "Useful for rewarding communication." ) + _accepts = _p( + 'guildset:word_xp|accepts', + "Number of XP to reward per hundred words sent." + ) _default = 50 _model = CoreData.Guild @@ -92,6 +102,14 @@ class TextTrackerSettings(SettingGroup): "Messages sent in these channels will not count towards a member's message XP. " "If a category is selected, then all channels under the category will also be untracked." ) + _accepts = _p( + 'guildset:untracked_text_channels|accepts', + "Comma separated list of untracked text channel names or ids." + ) + _notset_str = _p( + 'guildset:untracked_text_channels|notset', + "Not Set (all text channels will be tracked.)" + ) _default = None _table_interface = TextTrackerData.untracked_channels @@ -101,6 +119,29 @@ class TextTrackerSettings(SettingGroup): _cache = {} + @property + def update_message(self): + t = ctx_translator.get().t + if self.data: + resp = t(_p( + 'guildset:untracked_text_channels|set_response|set', + "Messages in or under the following channels will be ignored: {channels}" + )).format(channels=self.formatted) + else: + resp = t(_p( + 'guildset:untracked_text_channels|set_response|notset', + "Message XP will now be tracked in every channel." + )) + return resp + + @property + def set_str(self) -> str: + t = ctx_translator.get().t + return t(_p( + 'guildset:untracked_text_channels|set_using', + "Channel selector below" + )) + @classmethod @log_wrap(action='Cache Untracked Text Channels') async def setup(cls, bot): @@ -127,6 +168,7 @@ class TextTrackerGlobalSettings(SettingGroup): """ class XPPerPeriod(ModelData, IntegerSetting): setting_id = 'xp_per_period' + _set_cmd = 'leo configure experience_rates' _display_name = _p('botset:xp_per_period', "xp_per_5min") _desc = _p( @@ -139,6 +181,10 @@ class TextTrackerGlobalSettings(SettingGroup): "for each 5 minute period in which they are active (send a message). " "Note that this XP is only given *once* per period." ) + _accepts = _p( + 'botset:xp_per_period|accepts', + "Number of message XP to reward per 5 minute active period." + ) _default = 101 _model = TextTrackerData.BotConfigText @@ -155,6 +201,7 @@ class TextTrackerGlobalSettings(SettingGroup): class WordXP(ModelData, IntegerSetting): setting_id = 'word_xp' + _set_cmd = 'leo configure experience_rates' _display_name = _p('botset:word_xp', "xp_per_100words") _desc = _p( @@ -166,6 +213,10 @@ class TextTrackerGlobalSettings(SettingGroup): "Amount of global message XP to be given (additionally to the XP per period) for each hundred words. " "Useful for rewarding communication." ) + _accepts = _p( + 'botset:word_xp|accepts', + "Number of XP to reward per hundred words sent." + ) _default = 50 _model = TextTrackerData.BotConfigText diff --git a/src/tracking/text/ui.py b/src/tracking/text/ui.py index 5d4ad618..8c5ec072 100644 --- a/src/tracking/text/ui.py +++ b/src/tracking/text/ui.py @@ -86,7 +86,7 @@ class TextTrackerConfigUI(ConfigUI): class TextTrackerDashboard(DashboardSection): section_name = _p( 'dash:text_tracking|title', - "Message XP configuration", + "Message XP configuration ({commands[configure message_exp]})", ) configui = TextTrackerConfigUI setting_classes = configui.setting_classes diff --git a/src/tracking/voice/settings.py b/src/tracking/voice/settings.py index 4e02d2a0..df2aba00 100644 --- a/src/tracking/voice/settings.py +++ b/src/tracking/voice/settings.py @@ -33,9 +33,9 @@ _p = babel._p class VoiceTrackerSettings(SettingGroup): class UntrackedChannels(ListData, ChannelListSetting): - # TODO: Factor out into combined tracking settings? setting_id = 'untracked_channels' _event = 'guild_setting_update_untracked_channels' + _set_cmd = 'configure voice_rewards' _display_name = _p('guildset:untracked_channels', "untracked_channels") _desc = _p( @@ -47,6 +47,14 @@ class VoiceTrackerSettings(SettingGroup): "Activity in these channels will not count towards a member's statistics. " "If a category is selected, all channels under the category will be untracked." ) + _accepts = _p( + 'guildset:untracked_channels|accepts', + "Comma separated list of untracked channel name/ids." + ) + _notset_str = _p( + 'guildset:untracked_channels|notset', + "Not Set (all voice channels will be tracked.)" + ) _default = None @@ -68,12 +76,19 @@ class VoiceTrackerSettings(SettingGroup): @property def update_message(self): t = ctx_translator.get().t - return t(_p( - 'guildset:untracked_channels|response', - "Activity in the following channels will now be ignored: {channels}" - )).format( - channels=self.formatted - ) + if self.data: + resp = t(_p( + 'guildset:untracked_channels|set_response|set', + "Activity in the following channels will now be ignored: {channels}" + )).format( + channels=self.formatted + ) + else: + resp = t(_p( + 'guildset:untracked_channels|set_response|unset', + "All voice channels will now be tracked." + )) + return resp @classmethod @log_wrap(action='Cache Untracked Channels') @@ -97,6 +112,7 @@ class VoiceTrackerSettings(SettingGroup): class HourlyReward(ModelData, IntegerSetting): setting_id = 'hourly_reward' _event = 'guild_setting_update_hourly_reward' + _set_cmd = 'configure voice_rewards' _display_name = _p('guildset:hourly_reward', "hourly_reward") _desc = _p( @@ -107,6 +123,10 @@ class VoiceTrackerSettings(SettingGroup): 'guildset:hourly_reward|mode:voice|long_desc', "Number of LionCoins to each member per hour that they stay in a tracked voice channel." ) + _accepts = _p( + 'guildset:hourly_reward|accepts', + "Number of coins to reward per hour in voice." + ) _default = 50 _min = 0 @@ -127,29 +147,10 @@ class VoiceTrackerSettings(SettingGroup): amount=data ) - @property - def set_str(self): - # TODO: Dynamic retrieval of command id - return '' - class HourlyReward_Voice(HourlyReward): """ Voice-mode specialised version of HourlyReward """ - _desc = _p( - 'guildset:hourly_reward|mode:voice|desc', - "LionCoins given per hour in a voice channel." - ) - _long_desc = _p( - 'guildset:hourly_reward|mode:voice|long_desc', - "Number of LionCoins rewarded to each member per hour that they stay in a tracked voice channel." - ) - - @property - def set_str(self): - # TODO: Dynamic retrieval of command id - return '' - @property def update_message(self): t = ctx_translator.get().t @@ -191,6 +192,7 @@ class VoiceTrackerSettings(SettingGroup): """ setting_id = 'hourly_live_bonus' _event = 'guild_setting_update_hourly_live_bonus' + _set_cmd = 'configure voice_rewards' _display_name = _p('guildset:hourly_live_bonus', "hourly_live_bonus") _desc = _p( @@ -203,6 +205,10 @@ class VoiceTrackerSettings(SettingGroup): "When a member streams or video-chats in a channel they will be given this bonus *additionally* " "to the `hourly_reward`." ) + _accepts = _p( + 'guildset:hourly_live_bonus|accepts', + "Number of bonus coins to reward per hour when live." + ) _default = 150 _min = 0 @@ -223,11 +229,6 @@ class VoiceTrackerSettings(SettingGroup): amount=data ) - @property - def set_str(self): - # TODO: Dynamic retrieval of command id - return '' - @property def update_message(self): t = ctx_translator.get().t @@ -242,6 +243,7 @@ class VoiceTrackerSettings(SettingGroup): class DailyVoiceCap(ModelData, DurationSetting): setting_id = 'daily_voice_cap' _event = 'guild_setting_update_daily_voice_cap' + _set_cmd = 'configure voice_rewards' _display_name = _p('guildset:daily_voice_cap', "daily_voice_cap") _desc = _p( @@ -254,6 +256,10 @@ class VoiceTrackerSettings(SettingGroup): "Tracking will resume at the start of the next day. " "The start of the day is determined by the configured guild timezone." ) + _accepts = _p( + 'guildset:daily_voice_cap|accepts', + "The maximum number of voice hours to track per day." + ) _default = 16 * 60 * 60 _default_multiplier = 60 * 60 @@ -263,11 +269,6 @@ class VoiceTrackerSettings(SettingGroup): _model = CoreData.Guild _column = CoreData.Guild.daily_study_cap.name - @property - def set_str(self): - # TODO: Dynamic retrieval of command id - return '' - @property def update_message(self): t = ctx_translator.get().t @@ -524,7 +525,7 @@ class VoiceTrackerConfigUI(ConfigUI): class VoiceTrackerDashboard(DashboardSection): section_name = _p( 'dash:voice_tracker|title', - "Voice Tracker Configuration" + "Voice Tracker Configuration ({commands[configure voice_rewards]})" ) configui = VoiceTrackerConfigUI setting_classes = configui.setting_classes diff --git a/src/utils/lib.py b/src/utils/lib.py index 3cb52aab..d258337e 100644 --- a/src/utils/lib.py +++ b/src/utils/lib.py @@ -138,7 +138,7 @@ class MessageArgs: def tabulate( *fields: tuple[str, str], row_format: str = "`{invis}{key:<{pad}}{colon}`\t{value}", - sub_format: str = "`{invis:<{pad}}{invis}`\t{value}", + sub_format: str = "`{invis:<{pad}}{colon}`\t{value}", colon: str = ':', invis: str = "​", **args @@ -189,6 +189,7 @@ def tabulate( sub_line = sub_format.format( invis=invis, pad=max_len + len(colon), + colon=colon, value=line, **args ) diff --git a/src/utils/ui/config.py b/src/utils/ui/config.py index ae2aef1e..3a2823f4 100644 --- a/src/utils/ui/config.py +++ b/src/utils/ui/config.py @@ -83,6 +83,8 @@ class ConfigUI(LeoUI): t = ctx_translator.get().t instances = self.instances items = [setting.input_field for setting in instances] + # Filter out settings which don't have input fields + items = [item for item in items if item] strings = [item.value for item in items] modal = ConfigEditor(*items, title=t(self.edit_modal_title)) @@ -126,7 +128,7 @@ class ConfigUI(LeoUI): t = ctx_translator.get().t self.edit_button.label = t(_p( 'ui:configui|button:edit|label', - "Bulk Edit" + "Edit" )) @button(emoji=conf.emojis.cancel, style=ButtonStyle.red) @@ -287,7 +289,7 @@ class DashboardSection: # TODO: Header/description field table = self.make_table() page.add_field( - name=t(self.section_name), + name=t(self.section_name).format(bot=self.bot, commands=self.bot.core.mention_cache), value=table, inline=False )