feat(skins): Implement custom skin backend.
This commit is contained in:
@@ -4,6 +4,7 @@ active = [
|
||||
'.sysadmin',
|
||||
'.config',
|
||||
'.user_config',
|
||||
'.skins',
|
||||
'.schedule',
|
||||
'.economy',
|
||||
'.ranks',
|
||||
|
||||
10
src/modules/skins/__init__.py
Normal file
10
src/modules/skins/__init__.py
Normal 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
340
src/modules/skins/cog.py
Normal 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
117
src/modules/skins/data.py
Normal 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')
|
||||
0
src/modules/skins/editor/__init__.py
Normal file
0
src/modules/skins/editor/__init__.py
Normal file
0
src/modules/skins/editor/layout.py
Normal file
0
src/modules/skins/editor/layout.py
Normal file
0
src/modules/skins/editor/pages/__init__.py
Normal file
0
src/modules/skins/editor/pages/__init__.py
Normal file
0
src/modules/skins/editor/pages/leaderboard.py
Normal file
0
src/modules/skins/editor/pages/leaderboard.py
Normal file
0
src/modules/skins/editor/pages/monthly.py
Normal file
0
src/modules/skins/editor/pages/monthly.py
Normal file
0
src/modules/skins/editor/pages/monthly_goals.py
Normal file
0
src/modules/skins/editor/pages/monthly_goals.py
Normal file
0
src/modules/skins/editor/pages/profile.py
Normal file
0
src/modules/skins/editor/pages/profile.py
Normal file
0
src/modules/skins/editor/pages/stats.py
Normal file
0
src/modules/skins/editor/pages/stats.py
Normal file
0
src/modules/skins/editor/pages/summary.py
Normal file
0
src/modules/skins/editor/pages/summary.py
Normal file
0
src/modules/skins/editor/pages/weekly.py
Normal file
0
src/modules/skins/editor/pages/weekly.py
Normal file
0
src/modules/skins/editor/pages/weekly_goals.py
Normal file
0
src/modules/skins/editor/pages/weekly_goals.py
Normal file
0
src/modules/skins/editor/skineditor.py
Normal file
0
src/modules/skins/editor/skineditor.py
Normal file
0
src/modules/skins/editor/skinsetting.py
Normal file
0
src/modules/skins/editor/skinsetting.py
Normal file
54
src/modules/skins/settings.py
Normal file
54
src/modules/skins/settings.py
Normal 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
|
||||
|
||||
100
src/modules/skins/settingui.py
Normal file
100
src/modules/skins/settingui.py
Normal 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
|
||||
]
|
||||
175
src/modules/skins/skinlib.py
Normal file
175
src/modules/skins/skinlib.py
Normal 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
|
||||
0
src/modules/skins/userskinui.py
Normal file
0
src/modules/skins/userskinui.py
Normal file
Reference in New Issue
Block a user