rewrite: Localisation support.

This commit is contained in:
2022-11-23 13:11:41 +02:00
parent 2eea40f679
commit 0d5e801945
18 changed files with 666 additions and 27 deletions

6
bot/babel/__init__.py Normal file
View File

@@ -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))

290
bot/babel/cog.py Normal file
View File

@@ -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
]

34
bot/babel/enums.py Normal file
View File

@@ -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'

157
bot/babel/translator.py Normal file
View File

@@ -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 <locale: {locale}> <domain: {domain}>")
pass
else:
logger.debug(f"Loaded translator for <locale: {locale}> <domain: {domain}>")
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 <locale: {locale}> and <domain: {domain}>. 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)

View File

@@ -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

View File

@@ -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')

View File

@@ -0,0 +1,7 @@
from settings.groups import ModelSettings, SettingDotDict
from .data import CoreData
class GuildSettings(ModelSettings):
_settings = SettingDotDict()
model = CoreData.Guild

View File

@@ -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

View File

@@ -0,0 +1,7 @@
from settings.groups import ModelSettings, SettingDotDict
from .data import CoreData
class UserSettings(ModelSettings):
_settings = SettingDotDict()
model = CoreData.User

View File

@@ -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:

View File

@@ -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)

View File

@@ -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

View File

@@ -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):

View File

@@ -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):
"""

View File

@@ -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)

View File

@@ -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:

View File

@@ -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():

View File

@@ -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: