rewrite: Localisation support.
This commit is contained in:
6
bot/babel/__init__.py
Normal file
6
bot/babel/__init__.py
Normal 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
290
bot/babel/cog.py
Normal 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
34
bot/babel/enums.py
Normal 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
157
bot/babel/translator.py
Normal 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)
|
||||||
@@ -11,6 +11,8 @@ from settings.groups import SettingGroup
|
|||||||
|
|
||||||
from .data import CoreData
|
from .data import CoreData
|
||||||
from .lion import Lions
|
from .lion import Lions
|
||||||
|
from .guild_settings import GuildSettings
|
||||||
|
from .user_settings import UserSettings
|
||||||
|
|
||||||
|
|
||||||
class CoreCog(LionCog):
|
class CoreCog(LionCog):
|
||||||
@@ -31,12 +33,18 @@ class CoreCog(LionCog):
|
|||||||
self.guild_setting_groups: list[SettingGroup] = []
|
self.guild_setting_groups: list[SettingGroup] = []
|
||||||
self.user_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.app_cmd_cache: list[discord.app_commands.AppCommand] = []
|
||||||
self.cmd_name_cache: dict[str, 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)
|
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
|
ctx.alion = lion
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,9 @@ class CoreData(Registry, name="core"):
|
|||||||
API_timestamp BIGINT,
|
API_timestamp BIGINT,
|
||||||
gems INTEGER DEFAULT 0,
|
gems INTEGER DEFAULT 0,
|
||||||
first_seen TIMESTAMPTZ DEFAULT now(),
|
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()
|
gems = Integer()
|
||||||
first_seen = Timestamp()
|
first_seen = Timestamp()
|
||||||
last_seen = Timestamp()
|
last_seen = Timestamp()
|
||||||
|
locale = String()
|
||||||
|
locale_hint = String()
|
||||||
|
|
||||||
class Guild(RowModel):
|
class Guild(RowModel):
|
||||||
"""
|
"""
|
||||||
@@ -132,7 +136,9 @@ class CoreData(Registry, name="core"):
|
|||||||
pomodoro_channel BIGINT,
|
pomodoro_channel BIGINT,
|
||||||
name TEXT,
|
name TEXT,
|
||||||
first_joined_at TIMESTAMPTZ DEFAULT now(),
|
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()
|
first_joined_at = Timestamp()
|
||||||
left_at = Timestamp()
|
left_at = Timestamp()
|
||||||
|
|
||||||
|
locale = String()
|
||||||
|
force_locale = Bool()
|
||||||
|
|
||||||
unranked_rows = Table('unranked_rows')
|
unranked_rows = Table('unranked_rows')
|
||||||
|
|
||||||
donator_roles = Table('donator_roles')
|
donator_roles = Table('donator_roles')
|
||||||
|
|||||||
7
bot/core/guild_settings.py
Normal file
7
bot/core/guild_settings.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from settings.groups import ModelSettings, SettingDotDict
|
||||||
|
from .data import CoreData
|
||||||
|
|
||||||
|
|
||||||
|
class GuildSettings(ModelSettings):
|
||||||
|
_settings = SettingDotDict()
|
||||||
|
model = CoreData.Guild
|
||||||
@@ -9,6 +9,9 @@ from data import WeakCache
|
|||||||
|
|
||||||
from .data import CoreData
|
from .data import CoreData
|
||||||
|
|
||||||
|
from .user_settings import UserSettings
|
||||||
|
from .guild_settings import GuildSettings
|
||||||
|
|
||||||
|
|
||||||
class Lion:
|
class Lion:
|
||||||
"""
|
"""
|
||||||
@@ -25,9 +28,10 @@ class Lion:
|
|||||||
|
|
||||||
There is no guarantee that a corresponding discord Member actually exists.
|
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.data = data
|
||||||
self.user_data = user_data
|
self.user_data = user_data
|
||||||
self.guild_data = guild_data
|
self.guild_data = guild_data
|
||||||
@@ -52,6 +56,15 @@ class Lion:
|
|||||||
def get(cls, guildid, userid):
|
def get(cls, guildid, userid):
|
||||||
return cls._cache_.get((guildid, userid), None)
|
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
|
# Setting interfaces
|
||||||
# Each of these return an initialised member setting
|
# Each of these return an initialised member setting
|
||||||
|
|
||||||
@@ -74,7 +87,7 @@ class Lion:
|
|||||||
# Discord data cache
|
# Discord data cache
|
||||||
async def touch_discord_models(self, member: discord.Member):
|
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.
|
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?
|
# Can we do these in one query?
|
||||||
@@ -115,7 +128,7 @@ class Lions(LionCog):
|
|||||||
guild = await data.Guild.fetch_or_create(guildid)
|
guild = await data.Guild.fetch_or_create(guildid)
|
||||||
user = await data.User.fetch_or_create(userid)
|
user = await data.User.fetch_or_create(userid)
|
||||||
member = await data.Member.fetch_or_create(guildid, 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
|
self._cache_[(guildid, userid)] = lion
|
||||||
return lion
|
return lion
|
||||||
|
|
||||||
|
|||||||
7
bot/core/user_settings.py
Normal file
7
bot/core/user_settings.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from settings.groups import ModelSettings, SettingDotDict
|
||||||
|
from .data import CoreData
|
||||||
|
|
||||||
|
|
||||||
|
class UserSettings(ModelSettings):
|
||||||
|
_settings = SettingDotDict()
|
||||||
|
model = CoreData.User
|
||||||
@@ -12,6 +12,8 @@ from meta.context import ctx_bot
|
|||||||
|
|
||||||
from data import Database
|
from data import Database
|
||||||
|
|
||||||
|
from babel.translator import LeoBabel
|
||||||
|
|
||||||
from constants import DATA_VERSION
|
from constants import DATA_VERSION
|
||||||
|
|
||||||
|
|
||||||
@@ -46,12 +48,13 @@ async def main():
|
|||||||
shardname=shardname,
|
shardname=shardname,
|
||||||
db=db,
|
db=db,
|
||||||
config=conf,
|
config=conf,
|
||||||
initial_extensions=['core', 'analytics', 'modules'],
|
initial_extensions=['core', 'analytics', 'babel', 'modules'],
|
||||||
web_client=session,
|
web_client=session,
|
||||||
app_ipc=shard_talk,
|
app_ipc=shard_talk,
|
||||||
testing_guilds=conf.bot.getintlist('admin_guilds'),
|
testing_guilds=conf.bot.getintlist('admin_guilds'),
|
||||||
shard_id=sharding.shard_number,
|
shard_id=sharding.shard_number,
|
||||||
shard_count=sharding.shard_count
|
shard_count=sharding.shard_count,
|
||||||
|
translator=LeoBabel()
|
||||||
) as lionbot:
|
) as lionbot:
|
||||||
ctx_bot.set(lionbot)
|
ctx_bot.set(lionbot)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class LionBot(Bot):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self, *args, appname: str, shardname: str, db: Database, config: Conf,
|
self, *args, appname: str, shardname: str, db: Database, config: Conf,
|
||||||
initial_extensions: List[str], web_client: ClientSession, app_ipc,
|
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)
|
kwargs.setdefault('tree_cls', LionTree)
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
@@ -42,11 +42,15 @@ class LionBot(Bot):
|
|||||||
self.config = config
|
self.config = config
|
||||||
self.app_ipc = app_ipc
|
self.app_ipc = app_ipc
|
||||||
self.core: Optional['CoreCog'] = None
|
self.core: Optional['CoreCog'] = None
|
||||||
|
self.translator = translator
|
||||||
|
|
||||||
async def setup_hook(self) -> None:
|
async def setup_hook(self) -> None:
|
||||||
log_context.set(f"APP: {self.application_id}")
|
log_context.set(f"APP: {self.application_id}")
|
||||||
await self.app_ipc.connect()
|
await self.app_ipc.connect()
|
||||||
|
|
||||||
|
if self.translator is not None:
|
||||||
|
await self.tree.set_translator(self.translator)
|
||||||
|
|
||||||
for extension in self.initial_extensions:
|
for extension in self.initial_extensions:
|
||||||
await self.load_extension(extension)
|
await self.load_extension(extension)
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
from babel.translator import LocalBabel
|
||||||
|
babel = LocalBabel('settings_base')
|
||||||
|
|
||||||
from .data import ModelData
|
from .data import ModelData
|
||||||
from .base import BaseSetting
|
from .base import BaseSetting
|
||||||
from .ui import SettingWidget, InteractiveSetting
|
from .ui import SettingWidget, InteractiveSetting
|
||||||
|
from .groups import SettingDotDict, SettingGroup, ModelSettings, ModelSetting
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ class BaseSetting(Generic[ParentID, SettingData, SettingValue]):
|
|||||||
Additionally, the setting has attributes attached describing
|
Additionally, the setting has attributes attached describing
|
||||||
the setting in a user-friendly manner for display purposes.
|
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
|
_default: Optional[SettingData] = None # Default data value for the setting
|
||||||
|
|
||||||
def __init__(self, parent_id: ParentID, data: Optional[SettingData], **kwargs):
|
def __init__(self, parent_id: ParentID, data: Optional[SettingData], **kwargs):
|
||||||
|
|||||||
@@ -23,6 +23,15 @@ class ModelData:
|
|||||||
# High level data cache to use, leave as None to disable cache.
|
# High level data cache to use, leave as None to disable cache.
|
||||||
_cache = None # Map[id -> value]
|
_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
|
@classmethod
|
||||||
async def _reader(cls, parent_id, use_cache=True, **kwargs):
|
async def _reader(cls, parent_id, use_cache=True, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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 .ui import InteractiveSetting
|
||||||
|
from .base import BaseSetting
|
||||||
|
|
||||||
from utils.lib import tabulate
|
from utils.lib import tabulate
|
||||||
|
|
||||||
@@ -47,7 +52,7 @@ class SettingGroup:
|
|||||||
self.settings: SettingDotDict[InteractiveSetting] = self.__init_settings__()
|
self.settings: SettingDotDict[InteractiveSetting] = self.__init_settings__()
|
||||||
|
|
||||||
def attach(self, cls: Type[T], name: Optional[str] = None):
|
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
|
self.settings[name] = cls
|
||||||
return cls
|
return cls
|
||||||
|
|
||||||
@@ -77,3 +82,58 @@ class SettingGroup:
|
|||||||
row_format="[`{invis}{key:<{pad}}{colon}`](https://lionbot.org \"{field[2]}\")\t{value}"
|
row_format="[`{invis}{key:<{pad}}{colon}`](https://lionbot.org \"{field[2]}\")\t{value}"
|
||||||
)
|
)
|
||||||
return '\n'.join(table_rows)
|
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)
|
||||||
|
|||||||
@@ -8,17 +8,24 @@ from discord import ui
|
|||||||
from discord.ui.button import button, Button, ButtonStyle
|
from discord.ui.button import button, Button, ButtonStyle
|
||||||
|
|
||||||
from meta.context import context
|
from meta.context import context
|
||||||
from utils.lib import strfdur, parse_dur
|
|
||||||
from meta.errors import UserInputError
|
from meta.errors import UserInputError
|
||||||
|
from utils.lib import strfdur, parse_dur
|
||||||
|
from babel import ctx_translator
|
||||||
|
|
||||||
from .base import ParentID
|
from .base import ParentID
|
||||||
from .ui import InteractiveSetting, SettingWidget
|
from .ui import InteractiveSetting, SettingWidget
|
||||||
|
from . import babel
|
||||||
|
|
||||||
|
_, _p = babel._, babel._p
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from discord.guild import GuildChannel
|
from discord.guild import GuildChannel
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: Localise this file
|
||||||
|
|
||||||
|
|
||||||
class StringSetting(InteractiveSetting[ParentID, str, str]):
|
class StringSetting(InteractiveSetting[ParentID, str, str]):
|
||||||
"""
|
"""
|
||||||
Setting type mixin describing an arbitrary string type.
|
Setting type mixin describing an arbitrary string type.
|
||||||
@@ -34,7 +41,7 @@ class StringSetting(InteractiveSetting[ParentID, str, str]):
|
|||||||
Default: True
|
Default: True
|
||||||
"""
|
"""
|
||||||
|
|
||||||
accepts = "Any text"
|
accepts = _p('settype:bool|accepts', "Any text")
|
||||||
|
|
||||||
_maxlen: int = 4000
|
_maxlen: int = 4000
|
||||||
_quote: bool = True
|
_quote: bool = True
|
||||||
@@ -70,8 +77,14 @@ class StringSetting(InteractiveSetting[ParentID, str, str]):
|
|||||||
Provides some minor input validation.
|
Provides some minor input validation.
|
||||||
Treats an empty string as a `None` value.
|
Treats an empty string as a `None` value.
|
||||||
"""
|
"""
|
||||||
|
t = ctx_translator.get().t
|
||||||
if len(string) > cls._maxlen:
|
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:
|
elif len(string) == 0:
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -223,14 +223,14 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]):
|
|||||||
f"\nAccepts: {self.accepts}"
|
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.
|
Respond to an interaction which triggered a setting update.
|
||||||
Usually just wraps `update_message` in an embed and sends it back.
|
Usually just wraps `update_message` in an embed and sends it back.
|
||||||
Passes any extra `kwargs` to the message creation method.
|
Passes any extra `kwargs` to the message creation method.
|
||||||
"""
|
"""
|
||||||
embed = discord.Embed(
|
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()
|
colour=discord.Color.green()
|
||||||
)
|
)
|
||||||
if interaction.response.is_done():
|
if interaction.response.is_done():
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
|
-- Add metdata to configuration tables {{{
|
||||||
ALTER TABLE user_config ADD COLUMN name TEXT;
|
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 first_seen TIMESTAMPTZ DEFAULT now();
|
||||||
ALTER TABLE user_config ADD COLUMN last_seen TIMESTAMPTZ;
|
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(
|
CREATE TABLE app_config(
|
||||||
appname TEXT PRIMARY KEY,
|
appname TEXT PRIMARY KEY,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
@@ -47,9 +51,10 @@ CREATE TABLE bot_config_presence(
|
|||||||
activity_type ActivityType,
|
activity_type ActivityType,
|
||||||
activity_name Text
|
activity_name Text
|
||||||
);
|
);
|
||||||
|
-- }}}
|
||||||
|
|
||||||
|
|
||||||
-- Analytics data
|
-- Analytics data {{{
|
||||||
-- DROP SCHEMA IF EXISTS "analytics" CASCADE;
|
-- DROP SCHEMA IF EXISTS "analytics" CASCADE;
|
||||||
CREATE SCHEMA "analytics";
|
CREATE SCHEMA "analytics";
|
||||||
|
|
||||||
@@ -113,10 +118,18 @@ CREATE TABLE analytics.gui_renders(
|
|||||||
cardname TEXT NOT NULL,
|
cardname TEXT NOT NULL,
|
||||||
duration INTEGER NOT NULL
|
duration INTEGER NOT NULL
|
||||||
) INHERITS (analytics.events);
|
) INHERITS (analytics.events);
|
||||||
|
--- }}}
|
||||||
|
|
||||||
|
|
||||||
-- TODO: Correct foreign keys for member table
|
-- TODO: Correct foreign keys for member table
|
||||||
-- TODO: Add name to user
|
|
||||||
-- TODO: Add first_joined and last_left time to member
|
-- Localisation data {{{
|
||||||
-- TODO: Add first_seen and last_seen time to User
|
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');
|
INSERT INTO VersionHistory (version, author) VALUES (13, 'v12-v13 migration');
|
||||||
|
|
||||||
|
-- vim: set fdm=marker:
|
||||||
|
|||||||
Reference in New Issue
Block a user