From 0d5e80194579784bb7f424919ee5abe27fd26fe1 Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 23 Nov 2022 13:11:41 +0200 Subject: [PATCH] rewrite: Localisation support. --- bot/babel/__init__.py | 6 + bot/babel/cog.py | 290 ++++++++++++++++++++++++++++ bot/babel/enums.py | 34 ++++ bot/babel/translator.py | 157 +++++++++++++++ bot/core/cog.py | 12 +- bot/core/data.py | 13 +- bot/core/guild_settings.py | 7 + bot/core/lion.py | 21 +- bot/core/user_settings.py | 7 + bot/main.py | 7 +- bot/meta/LionBot.py | 6 +- bot/settings/__init__.py | 4 + bot/settings/base.py | 2 + bot/settings/data.py | 9 + bot/settings/groups.py | 64 +++++- bot/settings/setting_types.py | 19 +- bot/settings/ui.py | 4 +- data/migration/v12-13/migration.sql | 31 ++- 18 files changed, 666 insertions(+), 27 deletions(-) create mode 100644 bot/babel/__init__.py create mode 100644 bot/babel/cog.py create mode 100644 bot/babel/enums.py create mode 100644 bot/babel/translator.py create mode 100644 bot/core/guild_settings.py create mode 100644 bot/core/user_settings.py diff --git a/bot/babel/__init__.py b/bot/babel/__init__.py new file mode 100644 index 00000000..791d8e2e --- /dev/null +++ b/bot/babel/__init__.py @@ -0,0 +1,6 @@ +from .translator import SOURCE_LOCALE, LeoBabel, LocalBabel, LazyStr, ctx_locale, ctx_translator + + +async def setup(bot): + from .cog import BabelCog + await bot.add_cog(BabelCog(bot)) diff --git a/bot/babel/cog.py b/bot/babel/cog.py new file mode 100644 index 00000000..414cf6dd --- /dev/null +++ b/bot/babel/cog.py @@ -0,0 +1,290 @@ +""" +Babel Cog. + +Calculates and sets current locale before command runs (via check_once). +Also defines the relevant guild and user settings for localisation. +""" +from typing import Optional +import discord +from discord.ext import commands as cmds +from discord import app_commands as appcmds + +from meta import LionBot, LionCog, LionContext +from meta.errors import UserInputError + +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 + +babel = LocalBabel('babel') +_ = 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'} + + def __init__(self, bot: LionBot): + self.bot = bot + self.settings = LocaleSettings() + self.t = self.bot.translator.t + + async def cog_load(self): + if not self.bot.core: + raise ValueError("CoreCog must be loaded first!") + self.bot.core.guild_settings.attach(LocaleSettings.ForceLocale) + self.bot.core.guild_settings.attach(LocaleSettings.GuildLocale) + self.bot.core.user_settings.attach(LocaleSettings.UserLocale) + + async def cog_unload(self): + pass + + async def bot_check_once(self, ctx: LionContext): # type: ignore # Type checker doesn't understand coro checks + """ + Calculate and inject the current locale before the command begins. + + Locale resolution is calculated as follows: + If the guild has force_locale enabled, and a locale set, + then the guild's locale will be used. + + Otherwise, the priority is + user_locale -> command_locale -> user_locale_hint -> guild_locale -> default_locale + """ + locale = None + if ctx.guild: + forced = ctx.alion.guild_settings['force_locale'].value + guild_locale = ctx.alion.guild_settings['guild_locale'].value + if forced: + locale = guild_locale + + locale = locale or ctx.alion.user_settings['user_locale'].value + if ctx.interaction: + locale = locale or ctx.interaction.locale.value + if ctx.guild: + locale = locale or guild_locale + + locale = locale or SOURCE_LOCALE + + ctx_locale.set(locale) + 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) + + @cmds.hybrid_command( + name=_p('cmd:configure_language', "configure_language"), + description=_p('cmd:configure_language|desc', + "Configure the default language I will use in this server.") + ) + @appcmds.choices( + force_language=[ + appcmds.Choice(name=LocaleSettings.ForceLocale._outputs[True], value=1), + appcmds.Choice(name=LocaleSettings.ForceLocale._outputs[False], value=0), + ] + ) + @appcmds.guild_only() # Can be removed when attached as a subcommand + 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 + if not ctx.guild: + # This is impossible by decorators, but adding this guard for the type checker + return + t = self.t + # TODO: Setting group, and group setting widget + # We can attach the command to the setting group as an application command + # Then load it into the configure command group dynamically + + lang_setting = await self.settings.GuildLocale.get(ctx.guild.id) + force_setting = await self.settings.ForceLocale.get(ctx.guild.id) + + if language: + lang_data = await lang_setting._parse_string(ctx.guild.id, language) + if force_language is not None: + force_data = bool(force_language) + + 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! + raise UserInputError( + t(_p( + 'cmd:configure_language|error', + "You cannot enable `{force_setting}` without having a configured language!" + )).format(force_setting=t(LocaleSettings.ForceLocale.display_name)) + ) + # TODO: Really need simultaneous model writes, or batched writes + lines = [] + if language: + lang_setting.data = lang_data + await lang_setting.write() + lines.append(lang_setting.update_message) + if force_language is not None: + 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 + ) + ) + + @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' + ) + ] + else: + return [ + appcmds.Choice(name=lang, value=lang) + for lang in matching + ] diff --git a/bot/babel/enums.py b/bot/babel/enums.py new file mode 100644 index 00000000..45df5ffd --- /dev/null +++ b/bot/babel/enums.py @@ -0,0 +1,34 @@ +from enum import Enum + + +class LocaleMap(Enum): + american_english = 'en-US' + british_english = 'en-GB' + bulgarian = 'bg' + chinese = 'zh-CN' + taiwan_chinese = 'zh-TW' + croatian = 'hr' + czech = 'cs' + danish = 'da' + dutch = 'nl' + finnish = 'fi' + french = 'fr' + german = 'de' + greek = 'el' + hindi = 'hi' + hungarian = 'hu' + italian = 'it' + japanese = 'ja' + korean = 'ko' + lithuanian = 'lt' + norwegian = 'no' + polish = 'pl' + brazil_portuguese = 'pt-BR' + romanian = 'ro' + russian = 'ru' + spain_spanish = 'es-ES' + swedish = 'sv-SE' + thai = 'th' + turkish = 'tr' + ukrainian = 'uk' + vietnamese = 'vi' diff --git a/bot/babel/translator.py b/bot/babel/translator.py new file mode 100644 index 00000000..fa8c030b --- /dev/null +++ b/bot/babel/translator.py @@ -0,0 +1,157 @@ +import gettext +import logging +from contextvars import ContextVar +from collections import defaultdict +from enum import Enum + +from discord.app_commands import Translator, locale_str +from discord.enums import Locale + + +logger = logging.getLogger(__name__) + + +SOURCE_LOCALE = 'en_uk' +ctx_locale: ContextVar[str] = ContextVar('locale', default=SOURCE_LOCALE) +ctx_translator: ContextVar['LeoBabel'] = ContextVar('translator', default=None) # type: ignore + +null = gettext.NullTranslations() + + +class LeoBabel(Translator): + def __init__(self): + self.supported_locales = {loc.name for loc in Locale} + self.supported_domains = {} + self.translators = defaultdict(dict) # locale -> domain -> GNUTranslator + + def read_supported(self): + """ + Load supported localisations and domains from the config. + """ + from meta import conf + + locales = conf.babel.get('locales', '') + stripped = (loc.strip(', ') for loc in locales.split(',')) + self.supported_locales = {loc for loc in stripped if loc} + + domains = conf.babel.get('domains', '') + stripped = (dom.strip(', ') for dom in domains.split(',')) + self.supported_domains = {dom for dom in stripped if dom} + + async def load(self): + """ + Initialise the gettext translators for the supported_locales. + """ + self.read_supported() + for locale in self.supported_locales: + for domain in self.supported_domains: + if locale == SOURCE_LOCALE: + continue + try: + translator = gettext.translation(domain, "locales/", languages=[locale]) + except OSError: + # Presume translation does not exist + logger.warning(f"Could not load translator for supported ") + pass + else: + logger.debug(f"Loaded translator for ") + self.translators[locale][domain] = translator + + async def unload(self): + self.translators.clear() + + def get_translator(self, locale, domain): + if locale == SOURCE_LOCALE: + return null + + translator = self.translators[locale].get(domain, None) + if translator is None: + logger.warning( + f"Translator missing for requested and . Setting NullTranslator." + ) + self.translators[locale][domain] = null + translator = null + return translator + + def t(self, lazystr, locale=None): + domain = lazystr.domain + translator = self.get_translator(locale or lazystr.locale, domain) + return lazystr._translate_with(translator) + + async def translate(self, string: locale_str, locale: Locale, context): + if locale.value in self.supported_locales: + domain = string.extras.get('domain', None) + if domain is None: + logger.debug( + f"LeoBabel cannot translate a locale_str with no domain set. Context: {context}, String: {string}" + ) + return None + + translator = self.get_translator(locale.value, domain) + if not isinstance(string, LazyStr): + lazy = LazyStr(Method.GETTEXT, string.message) + else: + lazy = string + return lazy._translate_with(translator) + + +class Method(Enum): + GETTEXT = 'gettext' + NGETTEXT = 'ngettext' + PGETTEXT = 'pgettext' + NPGETTEXT = 'npgettext' + + +class LocalBabel: + def __init__(self, domain): + self.domain = domain + + @property + def methods(self): + return (self._, self._n, self._p, self._np) + + def _(self, message): + return LazyStr(Method.GETTEXT, message, domain=self.domain) + + def _n(self, singular, plural, n): + return LazyStr(Method.NGETTEXT, singular, plural, n, domain=self.domain) + + def _p(self, context, message): + return LazyStr(Method.PGETTEXT, context, message, domain=self.domain) + + def _np(self, context, singular, plural, n): + return LazyStr(Method.NPGETTEXT, context, singular, plural, n, domain=self.domain) + + +class LazyStr(locale_str): + __slots__ = ('method', 'args', 'domain', 'locale') + + def __init__(self, method, *args, locale=None, domain=None): + self.method = method + self.args = args + self.domain = domain + self.locale = locale or ctx_locale.get() + + @property + def message(self): + return self._translate_with(null) + + @property + def extras(self): + return {'locale': self.locale, 'domain': self.domain} + + def __str__(self): + return self.message + + def _translate_with(self, translator: gettext.GNUTranslations): + method = getattr(translator, self.method.value) + return method(*self.args) + + def __repr__(self) -> str: + return f'{self.__class__.__name__}({self.method}, {self.args!r}, locale={self.locale}, domain={self.domain})' + + def __eq__(self, obj: object) -> bool: + return isinstance(obj, locale_str) and self.message == obj.message + + def __hash__(self) -> int: + return hash(self.args) diff --git a/bot/core/cog.py b/bot/core/cog.py index a0978642..b286e55f 100644 --- a/bot/core/cog.py +++ b/bot/core/cog.py @@ -11,6 +11,8 @@ from settings.groups import SettingGroup from .data import CoreData from .lion import Lions +from .guild_settings import GuildSettings +from .user_settings import UserSettings class CoreCog(LionCog): @@ -31,12 +33,18 @@ class CoreCog(LionCog): self.guild_setting_groups: list[SettingGroup] = [] self.user_setting_groups: list[SettingGroup] = [] + # Some ModelSetting registries + # These are for more convenient direct access + self.guild_settings = GuildSettings + self.user_settings = UserSettings + self.app_cmd_cache: list[discord.app_commands.AppCommand] = [] self.cmd_name_cache: dict[str, discord.app_commands.AppCommand] = {} - async def bot_check_once(self, ctx: LionContext): + async def bot_check_once(self, ctx: LionContext): # type: ignore lion = await self.lions.fetch(ctx.guild.id if ctx.guild else 0, ctx.author.id) - await lion.touch_discord_models(ctx.author) + if ctx.guild: + await lion.touch_discord_models(ctx.author) # type: ignore # Type checker doesn't recognise guard ctx.alion = lion return True diff --git a/bot/core/data.py b/bot/core/data.py index 41559b3a..eb15080c 100644 --- a/bot/core/data.py +++ b/bot/core/data.py @@ -73,7 +73,9 @@ class CoreData(Registry, name="core"): API_timestamp BIGINT, gems INTEGER DEFAULT 0, first_seen TIMESTAMPTZ DEFAULT now(), - last_seen TIMESTAMPTZ + last_seen TIMESTAMPTZ, + locale TEXT, + locale_hint TEXT ); """ @@ -89,6 +91,8 @@ class CoreData(Registry, name="core"): gems = Integer() first_seen = Timestamp() last_seen = Timestamp() + locale = String() + locale_hint = String() class Guild(RowModel): """ @@ -132,7 +136,9 @@ class CoreData(Registry, name="core"): pomodoro_channel BIGINT, name TEXT, first_joined_at TIMESTAMPTZ DEFAULT now(), - left_at TIMESTAMPTZ + left_at TIMESTAMPTZ, + locale TEXT, + force_locale BOOLEAN ); """ @@ -191,6 +197,9 @@ class CoreData(Registry, name="core"): first_joined_at = Timestamp() left_at = Timestamp() + locale = String() + force_locale = Bool() + unranked_rows = Table('unranked_rows') donator_roles = Table('donator_roles') diff --git a/bot/core/guild_settings.py b/bot/core/guild_settings.py new file mode 100644 index 00000000..407235c0 --- /dev/null +++ b/bot/core/guild_settings.py @@ -0,0 +1,7 @@ +from settings.groups import ModelSettings, SettingDotDict +from .data import CoreData + + +class GuildSettings(ModelSettings): + _settings = SettingDotDict() + model = CoreData.Guild diff --git a/bot/core/lion.py b/bot/core/lion.py index ae790fe7..aa8da5a9 100644 --- a/bot/core/lion.py +++ b/bot/core/lion.py @@ -9,6 +9,9 @@ from data import WeakCache from .data import CoreData +from .user_settings import UserSettings +from .guild_settings import GuildSettings + class Lion: """ @@ -25,9 +28,10 @@ class Lion: There is no guarantee that a corresponding discord Member actually exists. """ - __slots__ = ('data', 'user_data', 'guild_data', '_member', '__weakref__') + __slots__ = ('bot', 'data', 'user_data', 'guild_data', '_member', '__weakref__') - def __init__(self, data: CoreData.Member, user_data: CoreData.User, guild_data: CoreData.Guild): + def __init__(self, bot: LionBot, data: CoreData.Member, user_data: CoreData.User, guild_data: CoreData.Guild): + self.bot = bot self.data = data self.user_data = user_data self.guild_data = guild_data @@ -52,6 +56,15 @@ class Lion: def get(cls, guildid, userid): return cls._cache_.get((guildid, userid), None) + # ModelSettings interfaces + @property + def guild_settings(self): + return GuildSettings(self.guildid, self.guild_data, bot=self.bot) + + @property + def user_settings(self): + return UserSettings(self.userid, self.user_data, bot=self.bot) + # Setting interfaces # Each of these return an initialised member setting @@ -74,7 +87,7 @@ class Lion: # Discord data cache async def touch_discord_models(self, member: discord.Member): """ - Update the stored discord data from the givem member. + Update the stored discord data from the given user or member object. Intended to be used when we get member data from events that may not be available in cache. """ # Can we do these in one query? @@ -115,7 +128,7 @@ class Lions(LionCog): guild = await data.Guild.fetch_or_create(guildid) user = await data.User.fetch_or_create(userid) member = await data.Member.fetch_or_create(guildid, userid) - lion = Lion(member, user, guild) + lion = Lion(self.bot, member, user, guild) self._cache_[(guildid, userid)] = lion return lion diff --git a/bot/core/user_settings.py b/bot/core/user_settings.py new file mode 100644 index 00000000..8dc69b0a --- /dev/null +++ b/bot/core/user_settings.py @@ -0,0 +1,7 @@ +from settings.groups import ModelSettings, SettingDotDict +from .data import CoreData + + +class UserSettings(ModelSettings): + _settings = SettingDotDict() + model = CoreData.User diff --git a/bot/main.py b/bot/main.py index 77b764f1..5d71b40f 100644 --- a/bot/main.py +++ b/bot/main.py @@ -12,6 +12,8 @@ from meta.context import ctx_bot from data import Database +from babel.translator import LeoBabel + from constants import DATA_VERSION @@ -46,12 +48,13 @@ async def main(): shardname=shardname, db=db, config=conf, - initial_extensions=['core', 'analytics', 'modules'], + initial_extensions=['core', 'analytics', 'babel', 'modules'], web_client=session, app_ipc=shard_talk, testing_guilds=conf.bot.getintlist('admin_guilds'), shard_id=sharding.shard_number, - shard_count=sharding.shard_count + shard_count=sharding.shard_count, + translator=LeoBabel() ) as lionbot: ctx_bot.set(lionbot) try: diff --git a/bot/meta/LionBot.py b/bot/meta/LionBot.py index 45c328d6..c64f45f8 100644 --- a/bot/meta/LionBot.py +++ b/bot/meta/LionBot.py @@ -28,7 +28,7 @@ class LionBot(Bot): def __init__( self, *args, appname: str, shardname: str, db: Database, config: Conf, initial_extensions: List[str], web_client: ClientSession, app_ipc, - testing_guilds: List[int] = [], **kwargs + testing_guilds: List[int] = [], translator=None, **kwargs ): kwargs.setdefault('tree_cls', LionTree) super().__init__(*args, **kwargs) @@ -42,11 +42,15 @@ class LionBot(Bot): self.config = config self.app_ipc = app_ipc self.core: Optional['CoreCog'] = None + self.translator = translator async def setup_hook(self) -> None: log_context.set(f"APP: {self.application_id}") await self.app_ipc.connect() + if self.translator is not None: + await self.tree.set_translator(self.translator) + for extension in self.initial_extensions: await self.load_extension(extension) diff --git a/bot/settings/__init__.py b/bot/settings/__init__.py index 760b5576..e34eec30 100644 --- a/bot/settings/__init__.py +++ b/bot/settings/__init__.py @@ -1,3 +1,7 @@ +from babel.translator import LocalBabel +babel = LocalBabel('settings_base') + from .data import ModelData from .base import BaseSetting from .ui import SettingWidget, InteractiveSetting +from .groups import SettingDotDict, SettingGroup, ModelSettings, ModelSetting diff --git a/bot/settings/base.py b/bot/settings/base.py index e17cf86e..d0da3656 100644 --- a/bot/settings/base.py +++ b/bot/settings/base.py @@ -26,6 +26,8 @@ class BaseSetting(Generic[ParentID, SettingData, SettingValue]): Additionally, the setting has attributes attached describing the setting in a user-friendly manner for display purposes. """ + setting_id: str # Unique source identifier for the setting + _default: Optional[SettingData] = None # Default data value for the setting def __init__(self, parent_id: ParentID, data: Optional[SettingData], **kwargs): diff --git a/bot/settings/data.py b/bot/settings/data.py index a745e499..b11ed1c7 100644 --- a/bot/settings/data.py +++ b/bot/settings/data.py @@ -23,6 +23,15 @@ class ModelData: # High level data cache to use, leave as None to disable cache. _cache = None # Map[id -> value] + @classmethod + def _read_from_row(cls, parent_id, row, **kwargs): + data = row[cls._column] + + if cls._cache is not None: + cls._cache[parent_id] = data + + return data + @classmethod async def _reader(cls, parent_id, use_cache=True, **kwargs): """ diff --git a/bot/settings/groups.py b/bot/settings/groups.py index ca469937..a50f0e9f 100644 --- a/bot/settings/groups.py +++ b/bot/settings/groups.py @@ -1,5 +1,10 @@ -from typing import Generic, Type, TypeVar, Optional +from typing import Generic, Type, TypeVar, Optional, overload + +from data import RowModel + +from .data import ModelData from .ui import InteractiveSetting +from .base import BaseSetting from utils.lib import tabulate @@ -47,7 +52,7 @@ class SettingGroup: self.settings: SettingDotDict[InteractiveSetting] = self.__init_settings__() def attach(self, cls: Type[T], name: Optional[str] = None): - name = name or cls.__name__ + name = name or cls.setting_id self.settings[name] = cls return cls @@ -77,3 +82,58 @@ class SettingGroup: row_format="[`{invis}{key:<{pad}}{colon}`](https://lionbot.org \"{field[2]}\")\t{value}" ) return '\n'.join(table_rows) + + +class ModelSetting(ModelData, BaseSetting): + ... + + +class ModelSettings: + """ + A ModelSettings instance aggregates multiple `ModelSetting` instances + bound to the same parent id on a single Model. + + This enables a single point of access + for settings of a given Model, + with support for caching or deriving as needed. + + This is an abstract base class, + and should be subclassed to define the contained settings. + """ + _settings: SettingDotDict = SettingDotDict() + model: Type[RowModel] + + def __init__(self, parent_id, row, **kwargs): + self.parent_id = parent_id + self.row = row + self.kwargs = kwargs + + @classmethod + async def fetch(cls, *parent_id, **kwargs): + """ + Load an instance of this ModelSetting with the given parent_id + and setting keyword arguments. + """ + row = await cls.model.fetch_or_create(*parent_id) + return cls(parent_id, row, **kwargs) + + @classmethod + def attach(self, setting_cls): + """ + Decorator to attach the given setting class to this modelsetting. + """ + # This violates the interface principle, use structured typing instead? + if not (issubclass(setting_cls, BaseSetting) and issubclass(setting_cls, ModelData)): + raise ValueError( + f"The provided setting class must be `ModelSetting`, not {setting_cls.__class__.__name__}." + ) + self._settings[setting_cls.setting_id] = setting_cls + return setting_cls + + def get(self, setting_id): + setting_cls = self._settings.get(setting_id) + data = setting_cls._read_from_row(self.parent_id, self.row, **self.kwargs) + return setting_cls(self.parent_id, data, **self.kwargs) + + def __getitem__(self, setting_id): + return self.get(setting_id) diff --git a/bot/settings/setting_types.py b/bot/settings/setting_types.py index 06d815df..756746ad 100644 --- a/bot/settings/setting_types.py +++ b/bot/settings/setting_types.py @@ -8,17 +8,24 @@ from discord import ui from discord.ui.button import button, Button, ButtonStyle from meta.context import context -from utils.lib import strfdur, parse_dur from meta.errors import UserInputError +from utils.lib import strfdur, parse_dur +from babel import ctx_translator from .base import ParentID from .ui import InteractiveSetting, SettingWidget +from . import babel + +_, _p = babel._, babel._p if TYPE_CHECKING: from discord.guild import GuildChannel +# TODO: Localise this file + + class StringSetting(InteractiveSetting[ParentID, str, str]): """ Setting type mixin describing an arbitrary string type. @@ -34,7 +41,7 @@ class StringSetting(InteractiveSetting[ParentID, str, str]): Default: True """ - accepts = "Any text" + accepts = _p('settype:bool|accepts', "Any text") _maxlen: int = 4000 _quote: bool = True @@ -70,8 +77,14 @@ class StringSetting(InteractiveSetting[ParentID, str, str]): Provides some minor input validation. Treats an empty string as a `None` value. """ + t = ctx_translator.get().t if len(string) > cls._maxlen: - raise UserInputError("Provided string is too long! Maximum length: {} characters.".format(cls._maxlen)) + raise UserInputError( + t(_p( + 'settype:bool|error', + "Provided string is too long! Maximum length: {maxlen} characters." + )).format(maxlen=cls._maxlen) + ) elif len(string) == 0: return None else: diff --git a/bot/settings/ui.py b/bot/settings/ui.py index 7d659c5f..80e65bd3 100644 --- a/bot/settings/ui.py +++ b/bot/settings/ui.py @@ -223,14 +223,14 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]): f"\nAccepts: {self.accepts}" )) - async def update_response(self, interaction: discord.Interaction, **kwargs): + async def update_response(self, interaction: discord.Interaction, message: Optional[str] = None, **kwargs): """ Respond to an interaction which triggered a setting update. Usually just wraps `update_message` in an embed and sends it back. Passes any extra `kwargs` to the message creation method. """ embed = discord.Embed( - description=f"{str(conf.emojis.tick)} {self.update_message}", + description=f"{str(conf.emojis.tick)} {message or self.update_message}", colour=discord.Color.green() ) if interaction.response.is_done(): diff --git a/data/migration/v12-13/migration.sql b/data/migration/v12-13/migration.sql index f04af6ca..dbd84d0e 100644 --- a/data/migration/v12-13/migration.sql +++ b/data/migration/v12-13/migration.sql @@ -1,13 +1,17 @@ +-- Add metdata to configuration tables {{{ ALTER TABLE user_config ADD COLUMN name TEXT; -ALTER TABLE guild_config ADD COLUMN first_joined_at TIMESTAMPTZ DEFAULT now(); -ALTER TABLE guild_config ADD COLUMN left_at TIMESTAMPTZ; -ALTER TABLE members ADD COLUMN first_joined TIMESTAMPTZ DEFAULT now(); -ALTER TABLE members ADD COLUMN last_left TIMESTAMPTZ; ALTER TABLE user_config ADD COLUMN first_seen TIMESTAMPTZ DEFAULT now(); ALTER TABLE user_config ADD COLUMN last_seen TIMESTAMPTZ; +ALTER TABLE guild_config ADD COLUMN first_joined_at TIMESTAMPTZ DEFAULT now(); +ALTER TABLE guild_config ADD COLUMN left_at TIMESTAMPTZ; --- Bot config data +ALTER TABLE members ADD COLUMN first_joined TIMESTAMPTZ DEFAULT now(); +ALTER TABLE members ADD COLUMN last_left TIMESTAMPTZ; +-- }}} + + +-- Bot config data {{{ CREATE TABLE app_config( appname TEXT PRIMARY KEY, created_at TIMESTAMPTZ NOT NULL DEFAULT now() @@ -47,9 +51,10 @@ CREATE TABLE bot_config_presence( activity_type ActivityType, activity_name Text ); +-- }}} --- Analytics data +-- Analytics data {{{ -- DROP SCHEMA IF EXISTS "analytics" CASCADE; CREATE SCHEMA "analytics"; @@ -113,10 +118,18 @@ CREATE TABLE analytics.gui_renders( cardname TEXT NOT NULL, duration INTEGER NOT NULL ) INHERITS (analytics.events); +--- }}} -- TODO: Correct foreign keys for member table --- TODO: Add name to user --- TODO: Add first_joined and last_left time to member --- TODO: Add first_seen and last_seen time to User + +-- Localisation data {{{ +ALTER TABLE user_config ADD COLUMN locale_hint TEXT; +ALTER TABLE user_config ADD COLUMN locale TEXT; +ALTER TABLE guild_config ADD COLUMN locale TEXT; +ALTER TABLE guild_config ADD COLUMN force_locale BOOLEAN; +--}}} + INSERT INTO VersionHistory (version, author) VALUES (13, 'v12-v13 migration'); + +-- vim: set fdm=marker: