feat(skins): Implement custom skin backend.

This commit is contained in:
2023-10-27 15:42:26 +03:00
parent 116bb869db
commit b47744e219
21 changed files with 797 additions and 0 deletions

View File

@@ -4,6 +4,7 @@ active = [
'.sysadmin',
'.config',
'.user_config',
'.skins',
'.schedule',
'.economy',
'.ranks',

View File

@@ -0,0 +1,10 @@
import logging
from babel.translator import LocalBabel
babel = LocalBabel('customskins')
logger = logging.getLogger(__name__)
async def setup(bot):
from .cog import CustomSkinCog
await bot.add_cog(CustomSkinCog(bot))

340
src/modules/skins/cog.py Normal file
View File

@@ -0,0 +1,340 @@
from typing import Optional
import asyncio
import discord
from discord.ext import commands as cmds
import discord.app_commands as appcmds
from cachetools import LRUCache
from bidict import bidict
from frozendict import frozendict
from meta import LionCog, LionBot, LionContext
from meta.logger import log_wrap
from utils.lib import MISSING, utc_now
from wards import sys_admin_ward, low_management_ward
from gui.base import AppSkin
from babel.translator import ctx_locale
from . import logger, babel
from .data import CustomSkinData
from .skinlib import appskin_as_choice, FrozenCustomSkin, CustomSkin
from .settings import GlobalSkinSettings
from .settingui import GlobalSkinSettingUI
_p = babel._p
class CustomSkinCog(LionCog):
def __init__(self, bot: LionBot):
self.bot = bot
self.data: CustomSkinData = bot.db.load_registry(CustomSkinData())
self.bot_settings = GlobalSkinSettings()
# Cache of app skin id -> app skin name
# After initialisation, contains all the base skins available for this app
self.appskin_names: bidict[int, str] = bidict()
# TODO: Move this to a bijection
# Bijective cache of skin property ids <-> (card_id, property_name) tuples
self.skin_properties: bidict[int, tuple[str, str]] = bidict()
# Cache of currently active user skins
# Invalidation handled by local event handler
self.active_user_skinids: LRUCache[int, Optional[int]] = LRUCache(maxsize=5000)
# Cache of custom skin id -> frozen custom skin
self.custom_skins: LRUCache[int, FrozenCustomSkin] = LRUCache(maxsize=1000)
async def cog_load(self):
await self.data.init()
if (leo_setting_cog := self.bot.get_cog('LeoSettings')) is not None:
leo_setting_cog.bot_setting_groups.append(self.bot_settings)
self.crossload_group(self.leo_group, leo_setting_cog.leo_group)
if (config_cog := self.bot.get_cog('ConfigCog')) is not None:
self.crossload_group(self.admin_group, config_cog.admin_group)
if (user_cog := self.bot.get_cog('UserConfigCog')) is not None:
self.crossload_group(self.my_group, user_cog.userconfig_group)
await self._reload_appskins()
await self._reload_property_map()
async def _reload_property_map(self):
"""
Reload the skin property id to (card_id, property_name) bijection.
"""
records = await self.data.skin_property_map.select_where()
cache = self.skin_properties
cache.clear()
for record in records:
cache[record['property_id']] = (record['card_id'], record['property_name'])
logger.info(
f"Loaded '{len(cache)}' custom skin properties."
)
async def _reload_appskins(self):
"""
Reload the global_available_skin id to the appskin name.
Create global_available_skins that don't already exist.
"""
cache = self.appskin_names
available = list(AppSkin.skins_data['skin_map'].keys())
rows = await self.data.GlobalSkin.fetch_where(skin_name=available)
cache.clear()
for row in rows:
cache[row.skin_id] = row.skin_name
# Not caring about efficiency here because this essentially needs to happen once ever
missing = [name for name in available if name not in cache.values()]
for name in missing:
row = await self.data.GlobalSkin.create(skin_name=name)
cache[row.skin_id] = row.skin_name
logger.info(
f"Loaded '{len(cache)}' global base skins."
)
# ----- Internal API -----
def get_base(self, base_skin_id: int) -> AppSkin:
"""
Initialise a localised AppSkin for the given base skin id.
"""
if base_skin_id not in self.appskin_names:
raise ValueError(f"Unknown app skin id '{base_skin_id}'")
return AppSkin.get(
skin_id=self.appskin_names[base_skin_id],
locale=ctx_locale.get(),
use_cache=True,
)
async def get_default_skin(self) -> Optional[str]:
"""
Get the current app-default skin, and return it as a skin name.
May be None if there is no app-default set.
This should almost always hit cache.
"""
setting = self.bot_settings.DefaultSkin
instance = await setting.get(self.bot.appname)
return instance.value
async def fetch_property_ids(self, *card_properties: tuple[str, str]) -> list[int]:
"""
Fetch the skin property ids for the given (card_id, property_name) tuples.
Creates any missing properties.
"""
mapper = self.skin_properties.inverse
missing = [prop for prop in card_properties if prop not in mapper]
if missing:
# First insert missing properties
await self.data.skin_property_map.insert_many(
('card_id', 'property_name'),
*missing
)
await self._reload_property_map()
return [mapper[prop] for prop in card_properties]
async def get_guild_skinid(self, guildid: int) -> Optional[int]:
"""
Fetch the custom_skin_id associated to the current guild.
Returns None if the guild is not premium or has no custom skin set.
Usually hits cache (Specifically the PremiumGuild cache).
"""
cog = self.bot.get_cog('PremiumCog')
if not cog:
logger.error(
"Trying to get guild skinid without loaded premium cog!"
)
return None
row = await cog.data.PremiumGuild.fetch(guildid)
return row.custom_skin_id if row else None
async def get_user_skinid(self, userid: int) -> Optional[int]:
"""
Fetch the custom_skin_id of the active skin in the given user's skin inventory.
Returns None if the user does not have an active skin.
Should usually be cached by `self.active_user_skinids`.
"""
skinid = self.active_user_skinids.get(userid, MISSING)
if skinid is MISSING:
rows = await self.data.UserSkin.fetch_where(userid=userid, active=True)
skinid = rows[0].custom_skin_id if rows else None
self.active_user_skinids[userid] = skinid
return skinid
async def args_for_skin(self, skinid: int, cardid: str) -> dict[str, str]:
"""
Fetch the skin argument dictionary for the given custom_skin_id.
Should usually be cached by `self.custom_skin_args`.
"""
skin = self.custom_skins.get(skinid, None)
if skin is None:
custom_skin = await CustomSkin.fetch(self.bot, skinid)
skin = custom_skin.freeze()
self.custom_skins[skinid] = skin
return skin.args_for(cardid)
# ----- External API -----
async def get_skinargs_for(self,
guildid: Optional[int], userid: Optional[int], card_id: str
) -> dict[str, str]:
"""
Get skin arguments for a standard GUI render with the given guild, user, and for the given card.
Takes into account the global defaults, guild custom skin, and user active skin.
"""
args = {}
if userid and (skinid := await self.get_user_skinid(userid)):
skin_args = await self.args_for_skin(skinid, card_id)
args.update(skin_args)
elif guildid and (skinid := await self.get_guild_skinid(guildid)):
skin_args = await self.args_for_skin(skinid, card_id)
args.update(skin_args)
default = await self.get_default_skin()
if default:
args.setdefault("base_skin_id", default)
return args
# ----- Event Handlers -----
@LionCog.listener('on_userset_skin')
async def refresh_user_skin(self, userid: int):
"""
Update cached user active skinid.
"""
self.active_user_skinids.pop(userid, None)
await self.get_user_skinid(userid)
@LionCog.listener('on_skin_updated')
async def refresh_custom_skin(self, skinid: int):
"""
Update cached args for given custom skin id.
"""
self.custom_skins.pop(skinid, None)
custom_skin = await CustomSkin.fetch(skinid)
if custom_skin is not None:
skin = custom_skin.freeze()
self.custom_skins[skinid] = skin
# ----- Userspace commands -----
@LionCog.placeholder_group
@cmds.hybrid_group("my", with_app_command=False)
async def my_group(self, ctx: LionContext):
...
@my_group.command(
name=_p('cmd:my_skin', "skin"),
description=_p(
'cmd:my_skin|desc',
"Change the colours of your interface"
)
)
async def cmd_my_skin(self, ctx: LionContext):
if not ctx.interaction:
return
# TODO
...
# ----- Adminspace commands -----
@LionCog.placeholder_group
@cmds.hybrid_group("admin", with_app_command=False)
async def admin_group(self, ctx: LionContext):
...
@admin_group.command(
name=_p('cmd:admin_brand', "brand"),
description=_p(
'cmd:admin_brand|desc',
"Fully customise my default interface for your members!"
)
)
@low_management_ward
async def cmd_admin_brand(self, ctx: LionContext):
if not ctx.interaction:
return
if not ctx.guild:
return
# TODO
...
# ----- Owner commands -----
@LionCog.placeholder_group
@cmds.hybrid_group("leo", with_app_command=False)
async def leo_group(self, ctx: LionContext):
...
@leo_group.command(
name=_p('cmd:leo_skin', "skin"),
description=_p(
'cmd:leo_skin|desc',
"View and update the global skin settings"
)
)
@appcmds.rename(
default_skin=_p('cmd:leo_skin|param:default_skin', "default_skin"),
)
@appcmds.describe(
default_skin=_p(
'cmd:leo_skin|param:default_skin|desc',
"Set the global default skin."
)
)
@sys_admin_ward
async def cmd_leo_skin(self, ctx: LionContext,
default_skin: Optional[str] = None):
if not ctx.interaction:
return
await ctx.interaction.response.defer(thinking=True)
modified = []
if default_skin is not None:
setting = self.bot_settings.DefaultSkin
instance = await setting.from_string(self.bot.appname, default_skin)
modified.append(instance)
for instance in modified:
await instance.write()
# No update_str, just show the config window
ui = GlobalSkinSettingUI(self.bot, self.bot.appname, ctx.channel.id)
await ui.run(ctx.interaction)
await ui.wait()
@cmd_leo_skin.autocomplete('default_skin')
async def cmd_leo_skin_acmpl_default_skin(self, interaction: discord.Interaction, partial: str):
babel = self.bot.get_cog('BabelCog')
ctx_locale.set(await babel.get_user_locale(interaction.user.id))
choices = []
for skinid in self.appskin_names:
appskin = self.get_base(skinid)
match = partial.lower()
if match in appskin.skin_id.lower() or match in appskin.display_name.lower():
choices.append(appskin_as_choice(appskin))
if not choices:
t = self.bot.translator.t
choices = [
appcmds.Choice(
name=t(_p(
'cmd:leo_skin|acmpl:default_skin|error:no_match',
"No app skins matching {partial}"
)).format(partial=partial)[:100],
value=partial
)
]
return choices

117
src/modules/skins/data.py Normal file
View File

@@ -0,0 +1,117 @@
from data import Registry, RowModel, Table
from data.columns import Integer, Bool, Timestamp, String
class CustomSkinData(Registry):
class GlobalSkin(RowModel):
"""
Schema
------
CREATE TABLE global_available_skins(
skin_id SERIAL PRIMARY KEY,
skin_name TEXT NOT NULL
);
CREATE INDEX global_available_skin_names ON global_available_skins (skin_name);
"""
_tablename_ = 'global_available_skins'
_cache_ = {}
skin_id = Integer(primary=True)
skin_name = String()
class CustomisedSkin(RowModel):
"""
Schema
------
CREATE TABLE customised_skins(
custom_skin_id SERIAL PRIMARY KEY,
base_skin_id INTEGER REFERENCES global_available_skins (skin_id),
_timestamp TIMESTAMPTZ DEFAULT now()
);
"""
_tablename_ = 'customised_skins'
custom_skin_id = Integer(primary=True)
base_skin_id = Integer()
_timestamp = Timestamp()
"""
Schema
------
CREATE TABLE customised_skin_property_ids(
property_id SERIAL PRIMARY KEY,
card_id TEXT NOT NULL,
property_name TEXT NOT NULL,
UNIQUE(card_id, property_name)
);
"""
skin_property_map = Table('customised_skin_property_ids')
"""
Schema
------
CREATE TABLE customised_skin_properties(
custom_skin_id INTEGER NOT NULL REFERENCES customised_skins (custom_skin_id),
property_id INTEGER NOT NULL REFERENCES customised_skin_property_ids (property_id),
value TEXT NOT NULL,
PRIMARY KEY (custom_skin_id, property_id)
);
CREATE INDEX customised_skin_property_skin_id ON customised_skin_properties(custom_skin_id);
"""
skin_properties = Table('customised_skin_properties')
"""
Schema
------
CREATE VIEW customised_skin_data AS
SELECT
skins.custom_skin_id AS custom_skin_id,
skins.base_skin_id AS base_skin_id,
properties.property_id AS property_id,
prop_ids.card_id AS card_id,
prop_ids.property_name AS property_name,
properties.value AS value
FROM
customised_skins skins
LEFT JOIN customised_skin_properties properties ON skins.custom_skin_id = properties.custom_skin_id
LEFT JOIN customised_skin_property_ids prop_ids ON properties.property_id = prop_ids.property_id;
"""
custom_skin_info = Table('customised_skin_data')
class UserSkin(RowModel):
"""
Schema
------
CREATE TABLE user_skin_inventory(
itemid SERIAL PRIMARY KEY,
userid BIGINT NOT NULL REFERENCES user_config (userid) ON DELETE CASCADE,
custom_skin_id INTEGER NOT NULL REFERENCES customised_skins (custom_skin_id) ON DELETE CASCADE,
transactionid INTEGER REFERENCES gem_transactions (transactionid),
active BOOLEAN NOT NULL DEFAULT FALSE,
acquired_at TIMESTAMPTZ DEFAULT now(),
expires_at TIMESTAMPTZ
);
CREATE INDEX user_skin_inventory_users ON user_skin_inventory(userid);
CREATE UNIQUE INDEX user_skin_inventory_active ON user_skin_inventory(userid) WHERE active = TRUE;
"""
_tablename_ = 'user_skin_inventory'
itemid = Integer(primary=True)
userid = Integer()
custom_skin_id = Integer()
transactionid = Integer()
active = Bool()
acquired_at = Timestamp()
expires_at = Timestamp()
"""
Schema
------
CREATE VIEW user_active_skins AS
SELECT
*
FROM user_skin_inventory
WHERE active=True;
"""
user_active_skins = Table('user_active_skins')

View File

View File

View File

View File

View File

View File

View File

@@ -0,0 +1,54 @@
from meta.errors import UserInputError
from settings.data import ModelData
from settings.setting_types import StringSetting
from settings.groups import SettingGroup
from wards import sys_admin_iward
from core.data import CoreData
from gui.base import AppSkin
from babel.translator import ctx_translator
from . import babel
_p = babel._p
class GlobalSkinSettings(SettingGroup):
class DefaultSkin(ModelData, StringSetting):
setting_id = 'default_app_skin'
_write_ward = sys_admin_iward
_display_name = _p(
'botset:default_app_skin', "default_skin"
)
_desc = _p(
'botset:default_app_skin|desc',
"The skin name of the app skin to use as the global default."
)
_long_desc = _p(
'botset:default_app_skin|long_desc',
"The skin name, as given in the `skins.json` file,"
" of the client default interface skin."
" Guilds and users will be able to apply this skin"
"regardless of whether it is set as visible in the skin configuration."
)
_accepts = _p(
'botset:default_app_skin|accepts',
"A valid skin name as given in skins.json"
)
_model = CoreData.BotConfig
_column = CoreData.BotConfig.default_skin.name
@classmethod
async def _parse_string(cls, parent_id, string, **kwargs):
t = ctx_translator.get().t
if string and not AppSkin.get_skin_path(string):
raise UserInputError(
t(_p(
'botset:default_app_skin|parse|error:invalid',
"Provided `{string}` is not a valid skin id!"
)).format(string=string)
)
return string or None

View File

@@ -0,0 +1,100 @@
import asyncio
import discord
from discord.ui.select import select, Select
from utils.ui import ConfigUI
from utils.lib import MessageArgs
from meta import LionBot
from . import babel, logger
from .settings import GlobalSkinSettings as Settings
from .skinlib import appskin_as_option
_p = babel._p
class GlobalSkinSettingUI(ConfigUI):
setting_classes = (
Settings.DefaultSkin,
)
def __init__(self, bot: LionBot, appname: str, channelid: int, **kwargs):
self.cog = bot.get_cog('CustomSkinCog')
super().__init__(bot, appname, channelid, **kwargs)
# ----- UI Components -----
@select(
cls=Select,
placeholder="DEFAULT_APP_MENU_PLACEHOLDER",
min_values=0, max_values=1
)
async def default_app_menu(self, selection: discord.Interaction, selected: Select):
await selection.response.defer(thinking=False)
setting = self.instances[0]
if selected.values:
setting.data = selected.values[0]
await setting.write()
else:
setting.data = None
await setting.write()
async def default_app_menu_refresh(self):
menu = self.default_app_menu
t = self.bot.translator.t
menu.placeholder = t(_p(
'ui:appskins|menu:default_app|placeholder',
"Select Default Skin"
))
options = []
for skinid in self.cog.appskin_names:
appskin = self.cog.get_base(skinid)
option = appskin_as_option(appskin)
option.default = (
self.instances[0].value == appskin.skin_id
)
options.append(option)
if options:
menu.options = options
else:
menu.disabled = True
menu.options = [
discord.SelectOption(label='DUMMY')
]
# ----- UI Flow -----
async def make_message(self) -> MessageArgs:
t = self.bot.translator.t
title = t(_p(
'ui:appskins|embed|title',
"Leo Global Skin Settings"
))
embed = discord.Embed(
title=title,
colour=discord.Colour.orange()
)
for setting in self.instances:
embed.add_field(**setting.embed_field, inline=False)
return MessageArgs(embed=embed)
async def refresh_components(self):
to_refresh = (
self.edit_button_refresh(),
self.close_button_refresh(),
self.reset_button_refresh(),
self.default_app_menu_refresh(),
)
await asyncio.gather(*to_refresh)
self.set_layout(
(self.edit_button, self.reset_button, self.close_button,),
(self.default_app_menu,),
)
async def reload(self):
self.instances = [
await setting.get(self.bot.appname)
for setting in self.setting_classes
]

View File

@@ -0,0 +1,175 @@
from collections import defaultdict
from typing import Optional
from frozendict import frozendict
import discord
from discord.components import SelectOption
from discord.app_commands import Choice
from gui.base import AppSkin
from meta import LionBot
from meta.logger import log_wrap
from .data import CustomSkinData
def appskin_as_option(skin: AppSkin) -> SelectOption:
"""
Create a SelectOption from the given localised AppSkin
"""
return SelectOption(
label=skin.display_name,
description=skin.description,
value=skin.skin_id,
)
def appskin_as_choice(skin: AppSkin) -> Choice[str]:
"""
Create an appcmds.Choice from the given localised AppSkin
"""
return Choice(
name=skin.display_name,
value=skin.skin_id,
)
class FrozenCustomSkin:
__slots__ = ('base_skin_name', 'properties')
def __init__(self, base_skin_name: Optional[str], properties: dict[str, dict[str, str]]):
self.base_skin_name = base_skin_name
self.properties = frozendict((card, frozendict(props)) for card, props in properties.items())
def args_for(self, card_id: str):
args = {}
if self.base_skin_name is not None:
args["base_skin_id"] = self.base_skin_name
if card_id in self.properties:
args.update(self.properties[card_id])
return args
class CustomSkin:
def __init__(self,
bot: LionBot,
base_skin_name: Optional[str]=None,
properties: dict[str, dict[str, str]] = {},
data: Optional[CustomSkinData.CustomisedSkin]=None,
):
self.bot = bot
self.data = data
self.base_skin_name = base_skin_name
self.properties = properties
@property
def cog(self):
return self.bot.get_cog('CustomSkinCog')
@property
def skinid(self) -> Optional[int]:
return self.data.custom_skin_id if self.data else None
@property
def base_skin_id(self) -> Optional[int]:
if self.base_skin_name is not None:
return self.cog.appskin_names.inverse[self.base_skin_name]
@classmethod
async def fetch(cls, bot: LionBot, skinid: int) -> Optional['CustomSkin']:
"""
Fetch the specified skin from data.
"""
cog = bot.get_cog('CustomSkinCog')
row = await cog.data.CustomisedSkin.fetch(skinid)
if row is not None:
records = await cog.data.custom_skin_info.select_where(
custom_skin_id=skinid
)
properties = defaultdict(dict)
for record in records:
card_id = record['card_id']
prop_name = record['property_name']
prop_value = record['property_value']
properties[card_id][prop_name] = prop_value
if row.base_skin_id is not None:
base_skin_name = cog.appskin_names[row.base_skin_id]
else:
base_skin_name = None
self = cls(bot, base_skin_name, properties, data=row)
return self
@log_wrap(action='Save Skin')
async def save(self):
if self.data is None:
raise ValueError("Cannot save a dataless CustomSkin")
async with self.bot.db.connection() as conn:
self.bot.db.conn = conn
async with conn.transaction():
skinid = self.skinid
await self.data.update(base_skin_id=self.base_skin_id)
await self.cog.data.skin_properties.delete_where(skinid=skinid)
props = {
(card, name): value
for card, card_props in self.properties.items()
for name, value in card_props.items()
if value is not None
}
# Ensure the properties exist in cache
await self.cog.fetch_property_ids(*props.keys())
# Now bulk insert
await self.cog.data.skin_properties.insert_many(
('custom_skin_id', 'property_id', 'value'),
*(
(skinid, self.cog.skin_properties[propkey], value)
for propkey, value in props.items()
)
)
def resolve_propid(self, propid: int) -> tuple[str, str]:
return self.cog.skin_properties[propid]
def __getitem__(self, propid: int) -> Optional[str]:
card, name = self.resolve_propid(propid)
return self.properties.get(card, {}).get(name, None)
def __setitem__(self, propid: int, value: Optional[str]):
card, name = self.resolve_propid(propid)
cardprops = self.properties.get(card, None)
if value is None:
if cardprops is not None:
cardprops.pop(name, None)
else:
if cardprops is None:
cardprops = self.properties[card] = {}
cardprops[name] = value
def __delitem__(self, propid: int):
card, name = self.resolve_propid(propid)
self.properties.get(card, {}).pop(name, None)
def freeze(self) -> FrozenCustomSkin:
"""
Freeze the custom skin data into a memory efficient FrozenCustomSkin.
"""
return FrozenCustomSkin(self.base_skin_name, self.properties)
def load_frozen(self, frozen: FrozenCustomSkin):
"""
Update state from the given frozen state.
"""
self.base_skin_name = frozen.base_skin_name
self.properties = dict((card, dict(props)) for card, props in frozen.properties)
return self
def args_for(self, card_id: str):
args = {}
if self.base_skin_name is not None:
args["base_skin_id"] = self.base_skin_name
if card_id in self.properties:
args.update(self.properties[card_id])
return args

View File