Merge pull request #67 from StudyLions/rewrite

Gems and Skins
This commit is contained in:
Interitio
2023-10-29 20:07:11 +02:00
committed by GitHub
49 changed files with 6505 additions and 52 deletions

View File

@@ -1,10 +1,45 @@
Copyright (c) 2022, Ari Horesh.
All rights reserved.
StudyLion Open Source License
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Copyright (c) 2023, Ari Horesh. All rights reserved.
### 1. Definitions
- "Software": Refers to the Discord bot named "StudyLion" and associated documentation, source code, scripts, assets, and other related materials.
- "Educational Use": Means utilization of the Software primarily for learning, teaching, training, research, or development.
- "Non-commercial Use": Describes an application of the Software where there's no expectation or realization of direct or indirect monetary compensation.
### 2. Grant of License
- Under the terms and conditions of this license, the licensor grants a worldwide, non-exclusive, royalty-free, non-transferable license to:
- Use the Software.
- Reproduce the Software.
- Modify the Software, creating derivative works based on the Software.
- Distribute the unmodified Software for Educational and Non-commercial Use.
### 3. Restrictions
- Redistribution, whether modified or unmodified, must:
- Preserve the above copyright notice.
- Incorporate this list of conditions.
- Include the following disclaimers.
- You must not:
- Use the name, trademarks, service marks, or names of the Software or its contributors to endorse or promote derivative products without prior written consent.
- Deploy the Software or any derivative works thereof for commercial purposes or any context leading to financial gain.
- Assert proprietary rights or assign authorship of the original Software to any entity other than the original authors.
- Grant sublicenses for the Software.
### 4. Contributions
- Any contributions made to the Software by third parties shall be subject to this license. The contributor grants the licensor a non-exclusive, perpetual, irrevocable license to any such contributions.
### 5. Termination
- If you breach any terms of this license, your rights will terminate automatically. Once terminated, you must:
- Halt all utilization of the Software.
- Destroy or delete all copies of the Software in your possession or control.
### 6. Disclaimer and Limitation of Liability
- THE SOFTWARE IS OFFERED "AS IS", WITHOUT ANY GUARANTEES OR CLAIMS OF EFFICACY. NO WARRANTIES, IMPLIED OR EXPLICIT, ARE PROVIDED. THIS INCLUDES, BUT IS NOT RESTRICTED TO, WARRANTIES OF MERCHANDISE, FITNESS FOR A SPECIFIC PURPOSE, AND NON-INFRINGEMENT.
- UNDER NO CONDITION SHALL THE AUTHORS, COPYRIGHT HOLDERS, OR CONTRIBUTORS BE ACCOUNTABLE FOR ANY CLAIMS, DAMAGES, OR OTHER LIABILITIES, WHETHER RESULTING FROM CONTRACT, TORT, NEGLIGENCE, OR ANY OTHER LEGAL THEORY, EMERGING FROM, OUT OF, OR CONNECTED WITH THE SOFTWARE OR ITS USE.

View File

@@ -1,13 +1,13 @@
## StudyLion - Discord Study & Productivity Bot
## LionBot (formerly StudyLion) - Discord Study & Productivity Bot
StudyLion is a Discord bot that tracks members' study and work time while offering members the ability to view their statistics and use productivity tools such as: To-do lists, pomodoro timers, reminders, and much more.
LionBot is a Discord bot that tracks members' study and work time while offering members the ability to view their statistics and use productivity tools such as: To-do lists, pomodoro timers, reminders, and much more.
[**Invite StudyLion here**](https://discord.studylions.com/invite "here"), and get started with `!help`.
[**Invite LionBot here**](https://discord.com/oauth2/authorize?client_id=889078613817831495&permissions=8&scope=bot), and get started with `/help`.
Join the [**support server**](https://discord.gg/studylions "support server") to contact us if you need help configuring the bot on your server, or start a [**discussion**](https://github.com/StudyLions/StudyLion/discussions "disscussion") to report issues and bugs.
Join the [**support server**](https://discord.gg/the-study-lions-780195610154237993) to contact us if you need help configuring the bot on your server, or start a [**discussion**](https://github.com/LionBots/LionBot/discussions "disscussion") to report issues and bugs.
@@ -18,14 +18,11 @@ In the past couple of years, we noticed a new trend on Discord instead of be
This bot was founder by [Ari Horesh](https://www.youtube.com/arihoresh) (Ari Horesh#0001) to support these forming study communities and allow students all over the world to study better.
This bot was founder by [Ari Horesh](https://www.youtube.com/arihoresh) (@AriHoresh) to support these forming study communities and allow students all over the world to study better.
### Self Hosting
We offer private instances based on availablity (a private bot for your community) to server owners who want their own branding (logo, color scheme, private and seperate database, better response-rate, and customizability to the text itself).
If you are intrested, contact the founder at contact@arihoresh.com .
You can self-host and fork the bot using the following steps, but beware that this version **does not include** our visual graphical user interface, which is only include in the custom private instances or our the public instance.
You can self-host and fork the bot using the following steps, but beware that we do not provide support for self-hosted instances. If you are interested in a privately managed instance (affordable paid service), contact Ari at contact@arihoresh.com
Follow the steps below to self-host the bot.
- Clone the repo recursively (which makes sure to include the cmdClient submodule, otherwise you need to initialise it separately)
@@ -38,7 +35,6 @@ We do not offer support for self-hosted bots, the code is provided as is without
## Features
- **Students Cards and Statistics**
Allow users to create their own private student profile cards and set customs study field tags by using `!stats` and `!setprofile`
@@ -108,8 +104,8 @@ Punish cheaters, audit-log, welcome message, and so much more using our full-sca
### Tutorials
A command list and general documentation for StudyLion may be found using the `!help` command, and documentation for a specific command, e.g. `config`, may be found with `!help config`.
A command list and general documentation for LionBot may be found using the `!help` command, and documentation for a specific command, e.g. `config`, may be found with `!help config`.
Make sure to check the [full documentation](https://www.notion.so/izabellakis/StudyLion-Bot-Tutorials-f493268fcd12436c9674afef2e151707 "StudyLion Tutorial") to stay updated.
Make sure to check the [full documentation](https://www.notion.so/izabellakis/LionBot-Bot-Tutorials-f493268fcd12436c9674afef2e151707 "LionBot Tutorial") to stay updated.
<a href="https://imgur.com/ziPdJGw"><img src="https://i.imgur.com/ziPdJGws.png" title="source: imgur.com" /></a>

View File

@@ -17,7 +17,7 @@ invite_bot =
[ENDPOINTS]
guild_log =
gem_transaction =
gem_log =
[LOGGING]
log_file = bot.log

View File

@@ -1218,7 +1218,7 @@ create TABLE timers(
inactivity_threshold INTEGER,
channel_name TEXT,
pretty_name TEXT,
owenrid BIGINT REFERENCES user_config,
ownerid BIGINT REFERENCES user_config,
manager_roleid BIGINT,
last_messageid BIGINT,
voice_alerts BOOLEAN,

View File

@@ -9,3 +9,5 @@ topggpy
psutil
pillow
python-dateutil
bidict
frozendict

View File

@@ -1,3 +1,4 @@
import logging
from typing import Optional
from collections import defaultdict
from weakref import WeakValueDictionary
@@ -19,6 +20,8 @@ from .lion_member import MemberConfig
from .lion_user import UserConfig
from .hooks import HookedChannel
logger = logging.getLogger(__name__)
class keydefaultdict(defaultdict):
def __missing__(self, key):
@@ -71,7 +74,6 @@ class CoreCog(LionCog):
self.bot.add_listener(self.shard_update_guilds, name='on_guild_join')
self.bot.add_listener(self.shard_update_guilds, name='on_guild_remove')
self.bot.core = self
await self.bot.add_cog(self.lions)
# Load the app command cache
@@ -127,3 +129,7 @@ class CoreCog(LionCog):
@log_wrap(action='Update shard guilds')
async def shard_update_guilds(self, guild):
await self.shard_data.update(guild_count=len(self.bot.guilds))
@LionCog.listener('on_ping')
async def handle_ping(self, *args, **kwargs):
logger.info(f"Received ping with args {args}, kwargs {kwargs}")

Submodule src/gui updated: f2760218ef...c1bcb05c25

View File

@@ -1,4 +1,4 @@
from typing import List, Optional, TYPE_CHECKING
from typing import List, Literal, LiteralString, Optional, TYPE_CHECKING, overload
import logging
import asyncio
from weakref import WeakValueDictionary
@@ -13,7 +13,7 @@ from aiohttp import ClientSession
from data import Database
from utils.lib import tabulate
from gui.errors import RenderingException
from babel.translator import ctx_locale
from babel.translator import ctx_locale, LeoBabel
from .config import Conf
from .logger import logging_context, log_context, log_action_stack, log_wrap, set_logging_context
@@ -24,16 +24,39 @@ from .errors import HandledException, SafeCancellation
from .monitor import SystemMonitor, ComponentMonitor, StatusLevel, ComponentStatus
if TYPE_CHECKING:
from core import CoreCog
from core.cog import CoreCog
from core.config import ConfigCog
from tracking.voice.cog import VoiceTrackerCog
from tracking.text.cog import TextTrackerCog
from modules.config.cog import GuildConfigCog
from modules.economy.cog import Economy
from modules.member_admin.cog import MemberAdminCog
from modules.meta.cog import MetaCog
from modules.moderation.cog import ModerationCog
from modules.pomodoro.cog import TimerCog
from modules.premium.cog import PremiumCog
from modules.ranks.cog import RankCog
from modules.reminders.cog import Reminders
from modules.rooms.cog import RoomCog
from modules.schedule.cog import ScheduleCog
from modules.shop.cog import ShopCog
from modules.skins.cog import CustomSkinCog
from modules.sponsors.cog import SponsorCog
from modules.statistics.cog import StatsCog
from modules.sysadmin.dash import LeoSettings
from modules.tasklist.cog import TasklistCog
from modules.topgg.cog import TopggCog
from modules.user_config.cog import UserConfigCog
from modules.video_channels.cog import VideoCog
logger = logging.getLogger(__name__)
class LionBot(Bot):
def __init__(
self, *args, appname: str, shardname: str, db: Database, config: Conf,
self, *args, appname: str, shardname: str, db: Database, config: Conf, translator: LeoBabel,
initial_extensions: List[str], web_client: ClientSession, app_ipc,
testing_guilds: List[int] = [], translator=None, **kwargs
testing_guilds: List[int] = [], **kwargs
):
kwargs.setdefault('tree_cls', LionTree)
super().__init__(*args, **kwargs)
@@ -46,7 +69,6 @@ class LionBot(Bot):
# self.appdata = appdata
self.config = config
self.app_ipc = app_ipc
self.core: 'CoreCog' = None
self.translator = translator
self.system_monitor = SystemMonitor()
@@ -56,6 +78,18 @@ class LionBot(Bot):
self._locks = WeakValueDictionary()
self._running_events = set()
self._talk_global_dispatch = app_ipc.register_route('dispatch')(self._handle_global_dispatch)
@property
def core(self):
return self.get_cog('CoreCog')
async def _handle_global_dispatch(self, event_name: str, *args, **kwargs):
self.dispatch(event_name, *args, **kwargs)
async def global_dispatch(self, event_name: str, *args, **kwargs):
await self._talk_global_dispatch(event_name, *args, **kwargs).broadcast(except_self=False)
async def _monitor_status(self):
if self.is_closed():
level = StatusLevel.ERRORED
@@ -99,6 +133,112 @@ class LionBot(Bot):
self.tree.copy_global_to(guild=guild)
await self.tree.sync(guild=guild)
# To make the type checker happy about fetching cogs by name
# TODO: Move this to stubs at some point
@overload
def get_cog(self, name: Literal['CoreCog']) -> 'CoreCog':
...
@overload
def get_cog(self, name: Literal['ConfigCog']) -> 'ConfigCog':
...
@overload
def get_cog(self, name: Literal['VoiceTrackerCog']) -> 'VoiceTrackerCog':
...
@overload
def get_cog(self, name: Literal['TextTrackerCog']) -> 'TextTrackerCog':
...
@overload
def get_cog(self, name: Literal['GuildConfigCog']) -> 'GuildConfigCog':
...
@overload
def get_cog(self, name: Literal['Economy']) -> 'Economy':
...
@overload
def get_cog(self, name: Literal['MemberAdminCog']) -> 'MemberAdminCog':
...
@overload
def get_cog(self, name: Literal['MetaCog']) -> 'MetaCog':
...
@overload
def get_cog(self, name: Literal['ModerationCog']) -> 'ModerationCog':
...
@overload
def get_cog(self, name: Literal['TimerCog']) -> 'TimerCog':
...
@overload
def get_cog(self, name: Literal['PremiumCog']) -> 'PremiumCog':
...
@overload
def get_cog(self, name: Literal['RankCog']) -> 'RankCog':
...
@overload
def get_cog(self, name: Literal['Reminders']) -> 'Reminders':
...
@overload
def get_cog(self, name: Literal['RoomCog']) -> 'RoomCog':
...
@overload
def get_cog(self, name: Literal['ScheduleCog']) -> 'ScheduleCog':
...
@overload
def get_cog(self, name: Literal['ShopCog']) -> 'ShopCog':
...
@overload
def get_cog(self, name: Literal['CustomSkinCog']) -> 'CustomSkinCog':
...
@overload
def get_cog(self, name: Literal['SponsorCog']) -> 'SponsorCog':
...
@overload
def get_cog(self, name: Literal['StatsCog']) -> 'StatsCog':
...
@overload
def get_cog(self, name: Literal['LeoSettings']) -> 'LeoSettings':
...
@overload
def get_cog(self, name: Literal['TasklistCog']) -> 'TasklistCog':
...
@overload
def get_cog(self, name: Literal['TopggCog']) -> 'TopggCog':
...
@overload
def get_cog(self, name: Literal['UserConfigCog']) -> 'UserConfigCog':
...
@overload
def get_cog(self, name: Literal['VideoCog']) -> 'VideoCog':
...
@overload
def get_cog(self, name: str) -> Optional[Cog]:
...
def get_cog(self, name: str) -> Optional[Cog]:
return super().get_cog(name)
async def add_cog(self, cog: Cog, **kwargs):
sup = super()
@log_wrap(action=f"Attach {cog.__cog_name__}")

View File

@@ -4,6 +4,7 @@ active = [
'.sysadmin',
'.config',
'.user_config',
'.skins',
'.schedule',
'.economy',
'.ranks',
@@ -20,6 +21,7 @@ active = [
'.meta',
'.sponsors',
'.topgg',
'.premium',
'.test',
]

View File

@@ -5,6 +5,7 @@ from typing import Optional
import discord
from core.lion_guild import LionGuild
from data.queries import ORDER
from meta import LionBot
from utils.lib import MessageArgs, jumpto, strfdelta, utc_now
from utils.monitor import TaskMonitor
@@ -86,7 +87,9 @@ class Ticket:
instantiate the correct classes.
"""
registry: ModerationData = bot.db.registries['ModerationData']
rows = await registry.Ticket.fetch_where(*args, **kwargs)
rows = await registry.Ticket.fetch_where(*args, **kwargs).order_by(
'created_at', ORDER.DESC,
)
tickets = []
if rows:
guildids = set(row.guildid for row in rows)

View File

@@ -46,6 +46,10 @@ async def get_timer_card(bot: LionBot, timer: 'Timer', stage: 'Stage'):
else:
card_cls = BreakTimerCard
skin = await bot.get_cog('CustomSkinCog').get_skinargs_for(
timer.data.guildid, None, card_cls.card_id
)
return card_cls(
name,
remaining,

View File

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

713
src/modules/premium/cog.py Normal file
View File

@@ -0,0 +1,713 @@
from typing import Optional
import asyncio
import discord
from discord.ext import commands as cmds
import discord.app_commands as appcmds
from discord.ui.button import Button, ButtonStyle
from discord.ui.text_input import TextInput, TextStyle
from meta import LionCog, LionBot, LionContext
from meta.errors import SafeCancellation, UserInputError
from meta.logger import log_wrap
from utils.lib import utc_now
from utils.ui import FastModal
from wards import sys_admin_ward
from constants import MAX_COINS
from . import logger, babel
from .data import PremiumData, GemTransactionType
from .ui.transactions import TransactionList
from .ui.premium import PremiumUI
from .errors import GemTransactionFailed, BalanceTooLow, BalanceTooHigh
_p = babel._p
class PremiumCog(LionCog):
buy_gems_link = "https://lionbot.org/donate"
def __init__(self, bot: LionBot):
self.bot = bot
self.data: PremiumData = bot.db.load_registry(PremiumData())
self.gem_logger: Optional[discord.Webhook] = None
async def cog_load(self):
await self.data.init()
if (leo_setting_cog := self.bot.get_cog('LeoSettings')) is not None:
self.crossload_group(self.leo_group, leo_setting_cog.leo_group)
if (gem_log_url := self.bot.config.endpoints.get('gem_log', None)) is not None:
self.gem_logger = discord.Webhook.from_url(gem_log_url, session=self.bot.web_client)
# ----- API -----
def buy_gems_button(self) -> Button:
t = self.bot.translator.t
button = Button(
style=ButtonStyle.link,
label=t(_p(
'button:gems|label',
"Buy Gems"
)),
emoji=self.bot.config.emojis.gem,
url=self.buy_gems_link,
)
return button
async def get_gem_balance(self, userid: int) -> int:
"""
Get the up-to-date gem balance for this user.
Creates the User row if it does not already exist.
"""
record = await self.bot.core.data.User.fetch(userid, cached=False)
if record is None:
record = await self.bot.core.data.User.create(userid=userid)
return record.gems
async def get_gift_count(self, userid: int) -> int:
"""
Compute the number of gifts this user has sent, by counting Transaction rows.
"""
record = await self.data.GemTransaction.table.select_where(
from_account=userid,
transaction_type=GemTransactionType.GIFT,
).select(
gift_count='COUNT(*)'
).with_no_adapter()
return record[0]['gift_count'] or 0
async def is_premium_guild(self, guildid: int) -> bool:
"""
Check whether the given guild currently has premium status.
"""
row = await self.data.PremiumGuild.fetch(guildid)
now = utc_now()
premium = (row is not None) and row.premium_until and (row.premium_until > now)
return premium
@log_wrap(isolate=True)
async def _add_gems(self, userid: int, amount: int):
"""
Transaction helper method to atomically add `amount` gems to account `userid`,
creating the account if required.
Do not use this method for a gem transaction. Use `gem_transaction` instead.
"""
async with self.bot.db.connection() as conn:
self.bot.db.conn = conn
async with conn.transaction():
model = self.bot.core.data.User
rows = await model.table.update_where(userid=userid).set(gems=model.gems + amount)
if not rows:
# User does not exist, create it
if amount < 0:
raise BalanceTooLow
if amount > MAX_COINS:
raise BalanceTooHigh
row = (await model.create(userid=userid, gems=amount)).data
else:
row = rows[0]
if row['gems'] < 0:
raise BalanceTooLow
async def gem_transaction(
self,
transaction_type: GemTransactionType,
*,
actorid: int,
from_account: Optional[int], to_account: Optional[int],
amount: int, description: str,
note: Optional[str] = None, reference: Optional[str] = None,
) -> PremiumData.GemTransaction:
"""
Perform a gem transaction with the given parameters.
This atomically creates a row in the 'gem_transactions' table,
updates the account balances,
and posts in the gem audit log.
Parameters
----------
transaction_type: GemTransactionType
The type of transaction.
actorid: int
The userid of the actor who initiated this transaction.
Automatic actions (e.g. webhook triggered) may have their own unique id.
from_account: Optional[int]
The userid of the source account.
May be `None` if there is no source account (e.g. manual modification by admin).
to_account: Optional[int]
The userid of the destination account.
May be `None` if there is no destination account.
amount: int
The number of LionGems to transfer.
description: str
An informative description of the transaction for auditing purposes.
Should include the pathway (e.g. command) through which the transaction was executed.
note: Optional[str]
Optional user-readable note added by the actor.
Usually attached in a notification visible by the target.
(E.g. thanks message from system/admin, or note attached to gift.)
reference: str
Optional admin-readable transaction reference.
This may be the message link of a command message,
or an external id/reference for an automatic transaction.
Raises
------
BalanceTooLow:
Raised if either source or target account would go below 0.
"""
async with self.bot.db.connection() as conn:
self.bot.db.conn = conn
async with conn.transaction():
if from_account is not None:
await self._add_gems(from_account, -amount)
if to_account is not None:
await self._add_gems(to_account, amount)
row = await self.data.GemTransaction.create(
transaction_type=transaction_type,
actorid=actorid,
from_account=from_account,
to_account=to_account,
amount=amount,
description=description,
note=note,
reference=reference,
)
logger.info(
f"LionGem Transaction performed. Transaction data: {row!r}"
)
await self.audit_log(row)
return row
async def audit_log(self, row: PremiumData.GemTransaction):
"""
Log the provided gem transaction to the global gem audit log.
If this fails, or the audit log does not exist, logs a warning.
"""
posted = False
if self.gem_logger is not None:
embed = discord.Embed(
colour=discord.Colour.orange(),
title=f"Gem Transaction #{row.transactionid}",
timestamp=row._timestamp,
)
embed.add_field(name="Type", value=row.transaction_type.name)
embed.add_field(name="Amount", value=str(row.amount))
embed.add_field(name="Actor", value=f"<@{row.actorid}>")
embed.add_field(name="From Account", value=f"<@{row.from_account}>" if row.from_account else 'None')
embed.add_field(name="To Account", value=f"<@{row.to_account}>" if row.to_account else 'None')
embed.add_field(name='Description', value=str(row.description), inline=False)
if row.note:
embed.add_field(name='Note', value=str(row.note), inline=False)
if row.reference:
embed.add_field(name='Reference', value=str(row.reference), inline=False)
try:
await self.gem_logger.send(embed=embed)
posted = True
except discord.HTTPException:
pass
if not posted:
logger.warning(
f"Missed gem audit logging for gem transaction: {row!r}"
)
# ----- User Commands -----
@cmds.hybrid_command(
name=_p('cmd:free', "free"),
description=_p(
'cmd:free|desc',
"Get free LionGems!"
)
)
async def cmd_free(self, ctx: LionContext):
t = self.bot.translator.t
content = t(_p(
'cmd:free|embed|description',
"You can get free LionGems by sharing our project on your Discord server and social media!\n"
"If you have well-established, or YouTube, Instagram, and TikTok accounts,"
" we will reward you for creating videos and content about the bot.\n"
"If you have a big server, you can promote our project and get LionGems in return.\n"
"For more details, contact `arihoresh` or open a Ticket in the [support server](https://discord.gg/studylions)."
))
thumb = "https://cdn.discordapp.com/attachments/890619584158265405/972791204498530364/Untitled_design_44.png"
title = t(_p(
'cmd:free|embed|title',
"Get FREE LionGems!"
))
embed = discord.Embed(
title=title,
description=content,
colour=0x41f097
)
embed.set_thumbnail(url=thumb)
await ctx.reply(embed=embed)
@cmds.hybrid_command(
name=_p('cmd:gift', "gift"),
description=_p(
'cmd:gift|desc',
"Gift your LionGems to another user!"
)
)
@appcmds.rename(
user=_p('cmd:gift|param:user', "user"),
amount=_p('cmd:gift|param:amount', "amount"),
note=_p('cmd:gift|param:note', "note"),
)
@appcmds.describe(
user=_p(
'cmd:gift|param:user|desc',
"User to which you want to gift your LionGems."
),
amount=_p(
'cmd:gift|param:amount|desc',
"Number of LionGems to gift."
),
note=_p(
'cmd:gift|param:note|desc',
"Optional note to attach to your gift."
),
)
async def cmd_gift(self, ctx: LionContext,
user: discord.User,
amount: appcmds.Range[int, 1, MAX_COINS],
note: Optional[appcmds.Range[str, 1, 1024]] = None):
if not ctx.interaction:
return
t = self.bot.translator.t
# Validate target
if user.bot:
raise UserInputError(
t(_p(
'cmd:gift|error:target_bot',
"You cannot gift LionGems to bots!"
))
)
if user.id == ctx.author.id:
raise UserInputError(
t(_p(
'cmd:gift|error:target_is_author',
"You cannot gift LionGems to yourself!"
))
)
# Prepare and open gift confirmation modal
amount_field = TextInput(
label=t(_p(
'cmd:gift|modal:confirm|field:amount|label',
"Number of LionGems to Gift"
)),
default=str(amount),
required=True,
)
note_field = TextInput(
label=t(_p(
'cmd:gift|modal:confirm|field:note|label',
"Add an optional note to your gift"
)),
default=note or '',
required=False,
max_length=1024,
style=TextStyle.long,
)
modal = FastModal(
amount_field, note_field,
title=t(_p(
'cmd:gift|modal:confirm|title',
"Confirm LionGem Gift"
))
)
await ctx.interaction.response.send_modal(modal)
try:
interaction = await modal.wait_for(timeout=300)
except asyncio.TimeoutError:
# Presume user cancelled and wants to abort
raise SafeCancellation
await interaction.response.defer(thinking=False)
# Parse amount
amountstr = amount_field.value
if not amountstr.isdigit():
raise UserInputError(
t(_p(
'cmd:gift|error:parse_amount',
"Could not parse `{provided}` as a number!"
)).format(provided=amountstr)
)
amount = int(amountstr)
if amount == 0:
raise UserInputError(
t(_p(
'cmd:gift|error:amount_zero',
"Cannot gift `0` gems."
))
)
# Get author's balance, make sure they have enough
author_balance = await self.get_gem_balance(ctx.author.id)
if author_balance < amount:
raise UserInputError(
t(_p(
'cmd:gift|error:author_balance_too_low',
"Insufficient balance to send {gem}**{amount}**!\n"
"Current balance: {gem}**{balance}**"
)).format(
gem=self.bot.config.emojis.gem,
amount=amount,
balance=author_balance,
)
)
# Everything seems to be in order, run the transaction
try:
transaction = await self.gem_transaction(
GemTransactionType.GIFT,
actorid=ctx.author.id,
from_account=ctx.author.id, to_account=user.id,
amount=amount,
description="Gift given through command '/gift'",
note=note_field.value or None
)
except BalanceTooLow:
raise UserInputError(
t(_p(
'cmd:gift|error:balance_too_low',
"Insufficient Balance to complete gift!"
))
)
# Attempt to send note to user
thumb = "https://cdn.discordapp.com/attachments/925799205954543636/938704034578194443/C85AF926-9F75-466F-9D8E-D47721427F5D.png"
icon = "https://cdn.discordapp.com/attachments/925799205954543636/938703943683416074/4CF1C849-D532-4DEC-B4C9-0AB11F443BAB.png"
desc = t(_p(
'cmd:gift|target_msg|desc',
"You were just gifted {gem}**{amount}** by {user}!\n"
"To use them, use the command {skin_cmd} to change your graphics skin!"
)).format(
gem=self.bot.config.emojis.gem,
amount=amount,
user=ctx.author.mention,
skin_cmd=self.bot.core.mention_cmd('my skin'),
)
embed = discord.Embed(
description=desc,
colour=discord.Colour.orange()
)
embed.set_thumbnail(url=thumb)
embed.set_author(
name=t(_p('cmd:gift|target_msg|author:name', "LionGems Delivery!")),
icon_url=icon,
)
embed.set_footer(
text=t(_p(
'cmd:gift|target_msg|footer:text',
"You now have {balance} LionGems"
)).format(
balance=await self.get_gem_balance(user.id),
)
)
embed.timestamp = utc_now()
note = note_field.value
if note:
embed.add_field(
name=t(_p(
'cmd:gift|target_msg|field:note|name',
"The sender attached a note"
)),
value=note
)
notify_sent = False
try:
await user.send(embed=embed)
notify_sent = True
except discord.HTTPException:
logger.info(
f"Could not send LionGem gift target their gift notification. Transaction {transaction.transactionid}"
)
# Finally, send the ack back to the author
embed = discord.Embed(
colour=discord.Colour.brand_green(),
title=t(_p(
'cmd:gift|embed:success|title',
"Gift Sent!"
)),
description=t(_p(
'cmd:gift|embed:success|description',
"Your gift of {gem}**{amount}** is on its way to {target}!"
)).format(
gem=self.bot.config.emojis.gem,
amount=amount,
target=user.mention,
)
)
embed.set_footer(
text=t(_p(
'cmd:gift|embed:success|footer',
"New Balance: {balance} LionGems",
)).format(balance=await self.get_gem_balance(ctx.author.id))
)
if not notify_sent:
embed.add_field(
name="",
value=t(_p(
'cmd:gift|embed:success|field:notify_failed|value',
"Unfortunately, I couldn't tell them about it! "
"They might have direct messages with me turned off."
))
)
await ctx.reply(embed=embed, ephemeral=True)
@cmds.hybrid_command(
name=_p('cmd:premium', "premium"),
description=_p(
'cmd:premium|desc',
"Upgrade your server with LionGems!"
)
)
@appcmds.guild_only
async def cmd_premium(self, ctx: LionContext):
if not ctx.guild:
return
if not ctx.interaction:
return
ui = PremiumUI(self.bot, ctx.guild, ctx.luser, callerid=ctx.author.id)
await ui.run(ctx.interaction)
await ui.wait()
# ----- 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_gems', "gems"),
description=_p(
'cmd:leo_gems|desc',
"View and adjust a user's LionGem balance."
)
)
@appcmds.rename(
target=_p('cmd:leo_gems|param:target', "target"),
adjustment=_p('cmd:leo_gems|param:adjustment', "adjustment"),
note=_p('cmd:leo_gems|param:note', "note"),
reason=_p('cmd:leo_gems|param:reason', "reason")
)
@appcmds.describe(
target=_p(
'cmd:leo_gems|param:target|desc',
"Target user you wish to view or modify LionGems for."
),
adjustment=_p(
'cmd:leo_gems|param:adjustment|desc',
"Number of LionGems to add to the target's balance (may be negative to remove)"
),
note=_p(
'cmd:leo_gems|param:note|desc',
"Optional note to attach to the delivery message when adding LionGems."
),
reason=_p(
'cmd:leo_gems|param:reason|desc',
'Optional reason or context to add to the gem audit log for this transaction.'
)
)
@sys_admin_ward
async def cmd_leo_gems(self, ctx: LionContext,
target: discord.User,
adjustment: Optional[int] = None,
note: Optional[appcmds.Range[str, 0, 1024]] = None,
reason: Optional[appcmds.Range[str, 0, 1024]] = None,):
if not ctx.interaction:
return
t = self.bot.translator.t
if adjustment is None or adjustment == 0:
# History viewing pathway
ui = TransactionList(self.bot, target.id, callerid=ctx.author.id)
await ui.run(ctx.interaction)
await ui.wait()
else:
# Adjustment path
# Show confirmation modal with note and reason
adjustment_field = TextInput(
label=t(_p(
'cmd:leo_gems|adjust|modal:confirm|field:amount|label',
"Number of LionGems to add. May be negative."
)),
default=str(adjustment),
required=True,
)
note_field = TextInput(
label=t(_p(
'cmd:leo_gems|adjust|modal:confirm|field:note|label',
"Optional note to attach to delivery message."
)),
default=note,
style=TextStyle.long,
max_length=1024,
required=False,
)
reason_field = TextInput(
label=t(_p(
'cmd:leo_gems|adjust|modal:confirm|field:reason|label',
"Optional reason to add to the audit log."
)),
default=reason,
style=TextStyle.long,
max_length=1024,
required=False,
)
modal = FastModal(
adjustment_field, note_field, reason_field,
title=t(_p(
'cmd:leo_gems|adjust|modal:confirm|title',
"Confirm LionGem Adjustment"
))
)
await ctx.interaction.response.send_modal(modal)
try:
interaction = await modal.wait_for(timeout=300)
except asyncio.TimeoutError:
raise SafeCancellation
await interaction.response.defer(thinking=False)
# Parse values
try:
amount = int(adjustment_field.value)
except ValueError:
raise UserInputError(
t(_p(
'cmd:leo_gems|adjust|error:parse_adjustment',
"Could not parse `{given}` as an integer."
)).format(given=adjustment_field.value)
)
note = note_field.value or None
reason = reason_field.value or None
# Run transaction
try:
transaction = await self.gem_transaction(
GemTransactionType.ADMIN,
actorid=ctx.author.id,
from_account=None, to_account=target.id,
amount=amount,
description=f"Admin balance adjustment with '/leo gems'.\n{reason}",
note=note
)
except GemTransactionFailed:
raise UserInputError(
t(_p(
'cmd:leo_gems|adjust|error:unknown',
"Balance adjustment failed! Check logs for more information."
))
)
# DM user with note if applicable
if amount > 0:
thumb = "https://cdn.discordapp.com/attachments/925799205954543636/938704034578194443/C85AF926-9F75-466F-9D8E-D47721427F5D.png"
icon = "https://cdn.discordapp.com/attachments/925799205954543636/938703943683416074/4CF1C849-D532-4DEC-B4C9-0AB11F443BAB.png"
desc = t(_p(
'cmd:leo_gems|adjust|target_msg|desc',
"You were given {gem}**{amount}**!\n"
"To use them, use the command {skin_cmd} to change your graphics skin!"
)).format(
gem=self.bot.config.emojis.gem,
amount=amount,
skin_cmd=self.bot.core.mention_cmd('my skin'),
)
embed = discord.Embed(
description=desc,
colour=discord.Colour.orange()
)
embed.set_thumbnail(url=thumb)
embed.set_author(
name=t(_p('cmd:leo_gems|adjust|target_msg|author:name', "LionGems Delivery!")),
icon_url=icon,
)
embed.set_footer(
text=t(_p(
'cmd:leo_gems|adjust|target_msg|footer:text',
"You now have {balance} LionGems"
)).format(
balance=await self.get_gem_balance(target.id),
)
)
embed.timestamp = utc_now()
note = note_field.value
if note:
embed.add_field(
name=t(_p(
'cmd:lion_gems|adjust|target_msg|field:note|name',
"Note"
)),
value=note
)
try:
await target.send(embed=embed)
target_notified = True
except discord.HTTPException:
target_notified = False
else:
target_notified = None
# Ack the operation
embed = discord.Embed(
colour=discord.Colour.brand_green(),
title=t(_p(
'cmd:lion_gems|adjust|embed:success|title',
"Success"
)),
description=t(_p(
'cmd:lion_gems|adjust|embed:success|description',
"Added {gem}**{amount}** to {target}'s account.\n"
"They now have {gem}**{balance}**"
)).format(
gem=self.bot.config.emojis.gem,
target=target.mention,
amount=amount,
balance=await self.get_gem_balance(target.id),
)
)
if target_notified is False:
embed.add_field(
name="",
value=t(_p(
'cmd:lion_gems|adjust|embed:success|field:notify_failed|value',
"Could not notify the target, they probably have direct messages disabled."
))
)
await ctx.reply(embed=embed, ephemeral=True)

View File

@@ -0,0 +1,92 @@
from enum import Enum
from psycopg import sql
from meta.logger import log_wrap
from data import Registry, RowModel, RegisterEnum, Table
from data.columns import Integer, Bool, Column, Timestamp, String
class GemTransactionType(Enum):
"""
Schema
------
CREATE TYPE GemTransactionType AS ENUM (
'ADMIN',
'GIFT',
'PURCHASE',
'AUTOMATIC'
);
"""
ADMIN = 'ADMIN',
GIFT = 'GIFT',
PURCHASE = 'PURCHASE',
AUTOMATIC = 'AUTOMATIC',
class PremiumData(Registry):
GemTransactionType = RegisterEnum(GemTransactionType, 'GemTransactionType')
class GemTransaction(RowModel):
"""
Schema
------
CREATE TABLE gem_transactions(
transactionid SERIAL PRIMARY KEY,
transaction_type GemTransactionType NOT NULL,
actorid BIGINT NOT NULL,
from_account BIGINT,
to_account BIGINT,
amount INTEGER NOT NULL,
description TEXT NOT NULL,
note TEXT,
reference TEXT,
_timestamp TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX gem_transactions_from ON gem_transactions (from_account);
"""
_tablename_ = 'gem_transactions'
transactionid = Integer(primary=True)
transaction_type: Column[GemTransactionType] = Column()
actorid = Integer()
from_account = Integer()
to_account = Integer()
amount = Integer()
description = String()
note = String()
reference = String()
_timestamp = Timestamp()
class PremiumGuild(RowModel):
"""
Schema
------
CREATE TABLE premium_guilds(
guildid BIGINT PRIMARY KEY REFERENCES guild_config,
premium_since TIMESTAMPTZ NOT NULL DEFAULT now(),
premium_until TIMESTAMPTZ NOT NULL DEFAULT now(),
custom_skin_id INTEGER REFERENCES customised_skins
);
"""
_tablename_ = "premium_guilds"
_cache_ = {}
guildid = Integer(primary=True)
premium_since = Timestamp()
premium_until = Timestamp()
custom_skin_id = Integer()
"""
CREATE TABLE premium_guild_contributions(
contributionid SERIAL PRIMARY KEY,
userid BIGINT NOT NULL REFERENCES user_config,
guildid BIGINT NOT NULL REFERENCES premium_guilds,
transactionid INTEGER REFERENCES gem_transactions,
duration INTEGER NOT NULL,
_timestamp TIMESTAMPTZ DEFAULT now()
);
"""
premium_guild_contributions = Table('premium_guild_contributions')

View File

@@ -0,0 +1,19 @@
class GemTransactionFailed(Exception):
"""
Base exception class used when a gem transaction failed.
"""
pass
class BalanceTooLow(GemTransactionFailed):
"""
Exception raised when transaction results in a negative gem balance.
"""
pass
class BalanceTooHigh(GemTransactionFailed):
"""
Exception raised when transaction results in gem balance overflow.
"""
pass

View File

@@ -0,0 +1,286 @@
from typing import Optional, TYPE_CHECKING, NamedTuple
import asyncio
import datetime as dt
import discord
from discord.ui.button import button, Button, ButtonStyle
from psycopg import sql
from meta import LionBot, conf
from meta.logger import log_wrap
from core.lion_user import LionUser
from babel.translator import LazyStr
from meta.errors import ResponseTimedOut, UserInputError
from data import RawExpr
from modules.premium.errors import BalanceTooLow
from utils.ui import MessageUI, Confirm, AButton
from utils.lib import MessageArgs, utc_now
from .. import babel, logger
from ..data import GemTransactionType, PremiumData
if TYPE_CHECKING:
from ..cog import PremiumCog
_p = babel._p
class PremiumPlan(NamedTuple):
text: LazyStr
label: LazyStr
emoji: Optional[discord.PartialEmoji | discord.Emoji | str]
duration: int
price: int
plans = [
PremiumPlan(
_p('plan:three_months|text', "three months"),
_p('plan:three_months|label', "Three Months"),
None,
90,
4000
),
PremiumPlan(
_p('plan:one_year|text', "one year"),
_p('plan:one_year|label', "One Year"),
None,
365,
12000
),
PremiumPlan(
_p('plan:one_month|text', "one month"),
_p('plan:one_month|label', "One Month"),
None,
30,
1500
),
]
class PremiumUI(MessageUI):
def __init__(self, bot: LionBot, guild: discord.Guild, luser: LionUser, **kwargs):
super().__init__(**kwargs)
self.bot = bot
self.guild = guild
self.luser = luser
self.cog: 'PremiumCog' = bot.get_cog('PremiumCog') # type: ignore
# UI State
self.premium_status: Optional[PremiumData.PremiumGuild] = None
self.plan_buttons = self._plan_buttons()
self.link_button = self.cog.buy_gems_button()
# ----- API -----
# ----- UI Components -----
async def plan_button(self, press: discord.Interaction, pressed: Button, plan: PremiumPlan):
t = self.bot.translator.t
# Check Balance
if self.luser.data.gems < plan.price:
raise UserInputError(
t(_p(
'ui:premium|button:plan|error:insufficient_gems',
"You do not have enough LionGems to purchase this plan!"
))
)
# Confirm Purchase
confirm_msg = t(_p(
'ui:premium|button:plan|confirm|desc',
"Contributing **{plan_text}** of premium subscription for this server"
" will cost you {gem}**{plan_price}**.\n"
"Are you sure you want to proceed?"
)).format(
plan_text=t(plan.text),
gem=self.bot.config.emojis.gem,
plan_price=plan.price,
)
confirm = Confirm(confirm_msg, press.user.id)
confirm.embed.title = t(_p(
'ui:premium|button:plan|confirm|title',
"Confirm Server Upgrade"
))
confirm.embed.set_footer(
text=t(_p(
'ui:premium|button:plan|confirm|footer',
"Your current balance is {balance} LionGems"
)).format(balance=self.luser.data.gems)
)
confirm.embed.colour = 0x41f097
try:
result = await confirm.ask(press, ephemeral=True)
except ResponseTimedOut:
result = False
if not result:
await press.followup.send(
t(_p(
'ui:premium|button:plan|confirm|cancelled',
"Purchase cancelled! No LionGems were deducted from your account."
)),
ephemeral=True
)
return
# Write transaction, plan contribution, and new plan status, with potential rollback
try:
await self._do_premium_upgrade(plan)
except BalanceTooLow:
raise UserInputError(
t(_p(
'ui:premium|button:plan|error:insufficient_gems_post_confirm',
"Insufficient LionGems to purchase this plan!"
))
)
# Acknowledge premium
embed = discord.Embed(
colour=discord.Colour.brand_green(),
title=t(_p(
'ui:premium|button:plan|embed:success|title',
"Server Upgraded!"
)),
description=t(_p(
'ui:premium|button:plan|embed:success|desc',
"You have contributed **{plan_text}** of premium subscription to this server!"
)).format(plan_text=plan.text)
)
await press.followup.send(
embed=embed
)
await self.refresh()
@log_wrap(action='premium upgrade')
async def _do_premium_upgrade(self, plan: PremiumPlan):
async with self.bot.db.connection() as conn:
self.bot.db.conn = conn
async with conn.transaction():
# Perform the gem transaction
transaction = await self.cog.gem_transaction(
GemTransactionType.PURCHASE,
actorid=self.luser.userid,
from_account=self.luser.userid,
to_account=None,
amount=plan.price,
description=(
f"User purchased {plan.duration} days of premium"
f" for guild {self.guild.id} using the `PremiumUI`."
),
note=None,
reference=f"iid: {self._original.id if self._original else 'None'}"
)
model = self.cog.data.PremiumGuild
# Ensure the premium guild row exists
premium_guild = await model.fetch_or_create(self.guild.id)
# Extend the subscription
await model.table.update_where(guildid=self.guild.id).set(
premium_until=RawExpr(
sql.SQL("GREATEST(premium_until, now()) + {}").format(
sql.Placeholder()
),
(dt.timedelta(days=plan.duration),)
)
)
# Finally, record the user's contribution
await self.cog.data.premium_guild_contributions.insert(
userid=self.luser.userid, guildid=self.guild.id,
transactionid=transaction.transactionid, duration=plan.duration
)
def _plan_buttons(self) -> list[Button]:
"""
Generate the Plan buttons.
Intended to be used once, upon initialisation.
"""
t = self.bot.translator.t
buttons = []
for plan in plans:
butt = AButton(
label=t(plan.label),
emoji=plan.emoji,
style=ButtonStyle.blurple,
pass_kwargs={'plan': plan}
)
butt(self.plan_button)
self.add_item(butt)
buttons.append(butt)
return buttons
# ----- UI Flow -----
def _current_status(self) -> str:
t = self.bot.translator.t
if self.premium_status is None or self.premium_status.premium_until is None:
status = t(_p(
'ui:premium|current_status:none',
"__**Current Server Status:**__ Awaiting Upgrade."
))
elif self.premium_status.premium_until > utc_now():
status = t(_p(
'ui:premium|current_status:premium',
"__**Current Server Status:**__ Upgraded! Premium until {expiry}"
)).format(expiry=discord.utils.format_dt(self.premium_status.premium_until, 'd'))
else:
status = t(_p(
'ui:premium|current_status:none',
"__**Current Server Status:**__ Awaiting Upgrade. Premium status expired on {expiry}"
)).format(expiry=discord.utils.format_dt(self.premium_status.premium_until, 'd'))
return status
async def make_message(self) -> MessageArgs:
t = self.bot.translator.t
blurb = t(_p(
'ui:premium|embed|description',
"By supporting our project, you will get access to countless customisation features!\n\n"
"- **Rebranding:** Customizable HEX colours and"
" **beautiful premium skins** for all of your community members!\n"
"- **Remove the vote and sponsor prompt!**\n"
"- Access to all of the [future premium features](https://staging.lionbot.org/donate)\n\n"
"Both server owners and **regular users** can"
" **buy and gift a subscription for this server** using this command!\n"
"To support both Leo and your server, **use the buttons below**!"
)) + '\n\n' + self._current_status()
embed = discord.Embed(
colour=0x41f097,
title=t(_p(
'ui:premium|embed|title',
"Support Leo and Upgrade your Server!"
)),
description=blurb,
)
embed.set_thumbnail(
url="https://i.imgur.com/v1mZolL.png"
)
embed.set_image(
url="https://cdn.discordapp.com/attachments/824196406482305034/972405513570615326/premium_test.png"
)
embed.set_footer(
text=t(_p(
'ui:premium|embed|footer',
"Your current balance is {balance} LionGems."
)).format(balance=self.luser.data.gems)
)
return MessageArgs(embed=embed)
async def refresh_layout(self):
self.set_layout(
(*self.plan_buttons, self.link_button),
)
async def reload(self):
self.premium_status = await self.cog.data.PremiumGuild.fetch(self.guild.id, cached=False)
await self.luser.data.refresh()

View File

@@ -0,0 +1,198 @@
from typing import Optional
import asyncio
import datetime as dt
import discord
from discord.ui.button import button, Button, ButtonStyle
from meta import LionBot, conf
from data import ORDER
from utils.ui import MessageUI, input
from utils.lib import MessageArgs, tabulate
from .. import babel, logger
from ..data import PremiumData
_p = babel._p
class TransactionList(MessageUI):
block_len = 5
def __init__(self, bot: LionBot, userid: int, **kwargs):
super().__init__(**kwargs)
self.bot = bot
self.userid = userid
self._pagen = 0
self.blocks: list[list[PremiumData.GemTransaction]] = [[]]
@property
def page_count(self):
return len(self.blocks)
@property
def pagen(self):
self._pagen = self._pagen % self.page_count
return self._pagen
@pagen.setter
def pagen(self, value):
self._pagen = value % self.page_count
@property
def current_page(self):
return self.blocks[self.pagen]
# ----- UI Components -----
# Backwards
@button(emoji=conf.emojis.backward, style=ButtonStyle.grey)
async def prev_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer(thinking=True, ephemeral=True)
self.pagen -= 1
await self.refresh(thinking=press)
# Jump to page
@button(label="JUMP_PLACEHOLDER", style=ButtonStyle.blurple)
async def jump_button(self, press: discord.Interaction, pressed: Button):
"""
Jump-to-page button.
Loads a page-switch dialogue.
"""
t = self.bot.translator.t
try:
interaction, value = await input(
press,
title=t(_p(
'ui:transactions|button:jump|input:title',
"Jump to page"
)),
question=t(_p(
'ui:transactions|button:jump|input:question',
"Page number to jump to"
))
)
value = value.strip()
except asyncio.TimeoutError:
return
if not value.lstrip('- ').isdigit():
error_embed = discord.Embed(
title=t(_p(
'ui:transactions|button:jump|error:invalid_page',
"Invalid page number, please try again!"
)),
colour=discord.Colour.brand_red()
)
await interaction.response.send_message(embed=error_embed, ephemeral=True)
else:
await interaction.response.defer(thinking=True)
pagen = int(value.lstrip('- '))
if value.startswith('-'):
pagen = -1 * pagen
elif pagen > 0:
pagen = pagen - 1
self.pagen = pagen
await self.refresh(thinking=interaction)
async def jump_button_refresh(self):
component = self.jump_button
component.label = f"{self.pagen + 1}/{self.page_count}"
component.disabled = (self.page_count <= 1)
# Forward
@button(emoji=conf.emojis.forward, style=ButtonStyle.grey)
async def next_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer(thinking=True)
self.pagen += 1
await self.refresh(thinking=press)
# Quit
@button(emoji=conf.emojis.cancel, style=ButtonStyle.red)
async def quit_button(self, press: discord.Interaction, pressed: Button):
"""
Quit the UI.
"""
await press.response.defer()
await self.quit()
# ----- UI Flow -----
async def make_message(self) -> MessageArgs:
t = self.bot.translator.t
title = t(_p(
'ui:transactions|embed|title',
"Gem Transactions for user `{userid}`"
)).format(userid=self.userid)
rows = self.current_page
if rows:
embed = discord.Embed(
colour=discord.Colour.orange(),
title=title,
description=t(_p(
'ui:transactions|embed|desc:balance',
"User {target} has a LionGem balance of {gem}**{balance}**"
)).format(
gem=self.bot.config.emojis.gem,
target=f"<@{self.userid}>",
balance=await (self.bot.get_cog('PremiumCog')).get_gem_balance(self.userid),
)
)
for row in rows:
name = f"Transaction #{row.transactionid}"
table_rows = (
('timestamp', discord.utils.format_dt(row._timestamp)),
('type', row.transaction_type.name),
('amount', str(row.amount)),
('actor', f"<@{row.actorid}>"),
('from', f"`{row.from_account}`" if row.from_account else 'None'),
('to', f"`{row.to_account}`" if row.to_account else 'None'),
('reference', str(row.reference)),
)
table = '\n'.join(tabulate(*table_rows))
embed.add_field(
name=name,
value=f"{row.description}\n{table}",
inline=False
)
else:
embed = discord.Embed(
colour=discord.Colour.brand_red(),
description = t(_p(
'ui:transactions|embed|desc:no_transactions',
"This user has no related gem transactions!"
))
)
return MessageArgs(embed=embed)
async def refresh_layout(self):
to_refresh = (
self.jump_button_refresh(),
)
await asyncio.gather(*to_refresh)
if self.page_count > 1:
self.set_layout(
(self.prev_button, self.jump_button, self.quit_button, self.next_button),
)
else:
self.set_layout(
(self.quit_button,)
)
async def reload(self):
model = PremiumData.GemTransaction
rows = await model.fetch_where(
(model.from_account == self.userid) | (model.to_account == self.userid)
).order_by('_timestamp', ORDER.DESC)
blocks = [
rows[i:i+self.block_len]
for i in range(0, len(rows), self.block_len)
]
self.blocks = blocks or [[]]

View File

@@ -592,20 +592,25 @@ class RankCog(LionCog):
# Calculate destination
to_dm = lguild.config.get('dm_ranks').value
rank_channel = lguild.config.get('rank_channel').value
sent = False
if to_dm or not rank_channel:
if to_dm:
destination = member
embed.set_author(
name=guild.name,
icon_url=guild.icon.url if guild.icon else None
)
text = None
else:
try:
await destination.send(embed=embed)
sent = True
except discord.HTTPException:
if not rank_channel:
raise
if not sent and rank_channel:
destination = rank_channel
text = member.mention
# Post!
await destination.send(embed=embed, content=text)
await destination.send(content=text, embed=embed)
def get_message_map(self,
rank_type: RankType,

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

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

@@ -0,0 +1,388 @@
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.errors import UserInputError
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
from .userskinui import UserSkinUI
from .editor.skineditor import CustomSkinEditor
_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()
# 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)
self.current_default: Optional[str] = None
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()
await self.get_default_skin()
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)
self.current_default = instance.value
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 = self.current_default
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(self.bot, skinid)
if custom_skin is not None:
skin = custom_skin.freeze()
self.custom_skins[skinid] = skin
@LionCog.listener('on_botset_skin')
async def handle_botset_skin(self, appname, instance):
await self.bot.global_dispatch('global_botset_skin', appname)
@LionCog.listener('on_global_botset_skin')
async def refresh_default_skin(self, appname):
await self.bot.core.data.BotConfig.fetch(appname, cached=False)
await self.get_default_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
ui = UserSkinUI(self.bot, ctx.author.id, ctx.author.id)
await ui.run(ctx.interaction, ephemeral=True)
await ui.wait()
# ----- 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
t = self.bot.translator.t
# Check guild premium status
premiumcog = self.bot.get_cog('PremiumCog')
guild_row = await premiumcog.data.PremiumGuild.fetch(ctx.guild.id, cached=False)
if not guild_row:
raise UserInputError(
t(_p(
'cmd:admin_brand|error:not_premium',
"Only premium servers can modify their interface theme! "
"Use the {premium} command to upgrade your server."
)).format(premium=self.bot.core.mention_cmd('premium'))
)
await ctx.interaction.response.defer(thinking=True, ephemeral=False)
if guild_row.custom_skin_id is None:
# Create new custom skin
skin_data = await self.data.CustomisedSkin.create(
base_skin_id=self.appskin_names.inverse[self.current_default] if self.current_default else None
)
await guild_row.update(custom_skin_id=skin_data.custom_skin_id)
skinid = guild_row.custom_skin_id
custom_skin = await CustomSkin.fetch(self.bot, skinid)
if custom_skin is None:
raise ValueError("Invalid custom skin id")
# Open the CustomSkinEditor with this skin
ui = CustomSkinEditor(custom_skin, callerid=ctx.author.id)
await ui.send(ctx.channel)
await ctx.interaction.delete_original_response()
await ui.wait()
# ----- 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

@@ -0,0 +1,133 @@
from typing import Optional
from dataclasses import dataclass, field
import uuid
import discord
from discord.components import SelectOption
from babel.translator import LazyStr
from gui.base.Card import Card
from utils.lib import EmbedField, tabulate
from .skinsetting import SettingInputType, Setting
from ..skinlib import CustomSkin
@dataclass
class SettingGroup:
"""
Data class representing a collection of settings which are naturally
grouped together at interface level.
Typically the settings in a single SettingGroup are displayed
in the same embed field, the settings are edited with the same modal,
and the group represents a single option in the "setting group menu".
Setting groups do not correspond to any grouping at the Card or Skin level,
and may cross multiple cards.
"""
# The name and description strings are shown in the embed field and menu option
name: LazyStr
# Tuple of settings that are part of this setting group
settings: tuple[Setting, ...]
description: Optional[LazyStr] = None
# Whether the group should be displayed in a group or not
ungrouped: bool = False
# Whether the embed field should be inline
inline: bool = True
# Component custom id to identify the editing component
# Also used as the value of the select option
custom_id: str = str(uuid.uuid4())
@property
def editable_settings(self):
return tuple(setting for setting in self.settings if setting.input_type is SettingInputType.ModalInput)
def embed_field_for(self, skin: CustomSkin) -> EmbedField:
"""
Tabulates the contained settings and builds an embed field for the editor UI.
"""
t = skin.bot.translator.t
rows: list[tuple[str, str]] = []
for setting in self.settings:
name = t(setting.display_name)
value = setting.value_in(skin) or setting.default_value_in(skin)
formatted = setting.format_value_in(skin, value)
rows.append((name, formatted))
lines = tabulate(*rows)
table = '\n'.join(lines)
description = f"*{t(self.description)}*" if self.description else ''
embed_field = EmbedField(
name=t(self.name),
value=f"{description}\n{table}",
inline=self.inline,
)
return embed_field
def select_option_for(self, skin: CustomSkin) -> SelectOption:
"""
Makes a SelectOption referring to this setting group.
"""
t = skin.bot.translator.t
option = SelectOption(
label=t(self.name),
description=t(self.description) if self.description else None,
value=self.custom_id,
)
return option
@dataclass
class Page:
"""
Represents a page of skin settings for the skin editor UI.
"""
# Various string attributes of the page
display_name: LazyStr
editing_description: Optional[LazyStr] = None
preview_description: Optional[LazyStr] = None
visible_in_preview: bool = True
render_card: Optional[type[Card]] = None
groups: list[SettingGroup] = field(default_factory=list)
def make_embed_for(self, skin: CustomSkin) -> discord.Embed:
t = skin.bot.translator.t
embed = discord.Embed(
colour=discord.Colour.orange(),
title=t(self.display_name),
)
description_lines: list[str] = []
field_counter = 0
for group in self.groups:
field = group.embed_field_for(skin)
if group.ungrouped:
description_lines.append(field.value)
else:
embed.add_field(**field._asdict())
if not (field_counter) % 3:
embed.add_field(name='', value='')
field_counter += 1
field_counter += 1
if description_lines:
embed.description = '\n'.join(description_lines)
if self.render_card is not None:
embed.set_image(url='attachment://sample.png')
return embed

View File

@@ -0,0 +1,16 @@
from .stats import stats_page
from .profile import profile_page
from .summary import summary_page
from .weekly import weekly_page
from .monthly import monthly_page
from .weekly_goals import weekly_goal_page
from .monthly_goals import monthly_goal_page
from .leaderboard import leaderboard_page
pages = [
profile_page, stats_page,
weekly_page, monthly_page,
weekly_goal_page, monthly_goal_page,
leaderboard_page,
]

View File

@@ -0,0 +1,294 @@
from gui.cards import LeaderboardCard
from ... import babel
from ..skinsetting import ColourSetting, SkinSetting, ColoursSetting
from ..layout import Page, SettingGroup
_p = babel._p
"""
top_position_colour
top_name_colour
top_hours_colour
entry_position_colour
entry_position_highlight_colour
entry_name_colour
entry_hours_colour
header_text_colour
[subheader_name_colour, subheader_value_colour]
entry_bg_colour
entry_bg_highlight_colour
"""
leaderboard_page = Page(
display_name=_p('skinsettings|page:leaderboard|display_name', "Leaderboard"),
editing_description=_p(
'skinsettings|page:leaderboard|edit_desc',
"Options for the Leaderboard pages."
),
preview_description=None,
visible_in_preview=True,
render_card=LeaderboardCard
)
header_text_colour = ColourSetting(
card=LeaderboardCard,
property_name='header_text_colour',
display_name=_p(
'skinsettings|page:leaderboard|set:header_text_colour|display_name',
"Header"
),
description=_p(
'skinsettings|page:leaderboard|set:header_text_colour|desc',
"Text colour of the leaderboard header."
)
)
subheader_name_colour = ColourSetting(
card=LeaderboardCard,
property_name='subheader_name_colour',
display_name=_p(
'skinsettings|page:leaderboard|set:subheader_name_colour|display_name',
""
),
description=_p(
'skinsettings|page:leaderboard|set:subheader_name_colour|desc',
""
)
)
subheader_value_colour = ColourSetting(
card=LeaderboardCard,
property_name='subheader_value_colour',
display_name=_p(
'skinsettings|page:leaderboard|set:subheader_value_colour|display_name',
""
),
description=_p(
'skinsettings|page:leaderboard|set:subheader_value_colour|desc',
""
)
)
subheader_colour = ColoursSetting(
subheader_value_colour,
subheader_name_colour,
display_name=_p(
'skinsettings|page:leaderboard|set:subheader_colour|display_name',
"Sub-Header"
),
description=_p(
'skinsettings|page:leaderboard|set:subheader_colour|desc',
"Text colour of the sub-header line."
)
)
top_position_colour = ColourSetting(
card=LeaderboardCard,
property_name='top_position_colour',
display_name=_p(
'skinsettings|page:leaderboard|set:top_position_colour|display_name',
"Position"
),
description=_p(
'skinsettings|page:leaderboard|set:top_position_colour|desc',
"Top 3 position colour."
)
)
top_name_colour = ColourSetting(
card=LeaderboardCard,
property_name='top_name_colour',
display_name=_p(
'skinsettings|page:leaderboard|set:top_name_colour|display_name',
"Name"
),
description=_p(
'skinsettings|page:leaderboard|set:top_name_colour|desc',
"Top 3 name colour."
)
)
top_hours_colour = ColourSetting(
card=LeaderboardCard,
property_name='top_hours_colour',
display_name=_p(
'skinsettings|page:leaderboard|set:top_hours_colour|display_name',
"Hours"
),
description=_p(
'skinsettings|page:leaderboard|set:top_hours_colour|desc',
"Top 3 hours colour."
)
)
entry_position_colour = ColourSetting(
card=LeaderboardCard,
property_name='entry_position_colour',
display_name=_p(
'skinsettings|page:leaderboard|set:entry_position_colour|display_name',
"Position"
),
description=_p(
'skinsettings|page:leaderboard|set:entry_position_colour|desc',
"Position text colour."
)
)
entry_position_highlight_colour = ColourSetting(
card=LeaderboardCard,
property_name='entry_position_highlight_colour',
display_name=_p(
'skinsettings|page:leaderboard|set:entry_position_highlight_colour|display_name',
"Position (HL)"
),
description=_p(
'skinsettings|page:leaderboard|set:entry_position_highlight_colour|desc',
"Highlighted position colour."
)
)
entry_name_colour = ColourSetting(
card=LeaderboardCard,
property_name='entry_name_colour',
display_name=_p(
'skinsettings|page:leaderboard|set:entry_name_colour|display_name',
"Name"
),
description=_p(
'skinsettings|page:leaderboard|set:entry_name_colour|desc',
"Entry name colour."
)
)
entry_hours_colour = ColourSetting(
card=LeaderboardCard,
property_name='entry_hours_colour',
display_name=_p(
'skinsettings|page:leaderboard|set:entry_hours_colour|display_name',
"Hours"
),
description=_p(
'skinsettings|page:leaderboard|set:entry_hours_colour|desc',
"Entry hours colour."
)
)
entry_bg_colour = ColourSetting(
card=LeaderboardCard,
property_name='entry_bg_colour',
display_name=_p(
'skinsettings|page:leaderboard|set:entry_bg_colour|display_name',
"Regular"
),
description=_p(
'skinsettings|page:leaderboard|set:entry_bg_colour|desc',
"Background colour of regular entries."
)
)
entry_bg_highlight_colour = ColourSetting(
card=LeaderboardCard,
property_name='entry_bg_highlight_colour',
display_name=_p(
'skinsettings|page:leaderboard|set:entry_bg_highlight_colour|display_name',
"Highlighted"
),
description=_p(
'skinsettings|page:leaderboard|set:entry_bg_highlight_colour|desc',
"Background colour of highlighted entries."
)
)
top_colour_group = SettingGroup(
_p('skinsettings|page:leaderboard|grp:top_colour', "Top 3"),
description=_p(
'skinsettings|page:leaderboard|grp:top_colour|desc',
"Customise the text colours for the top 3 positions."
),
custom_id='leaderboard-top',
settings=(
top_position_colour,
top_name_colour,
top_hours_colour
)
)
entry_text_group = SettingGroup(
_p('skinsettings|page:leaderboard|grp:entry_text', "Entry Text"),
description=_p(
'skinsettings|page:leaderboard|grp:entry_text|desc',
"Text colours of the leaderboard entries."
),
custom_id='leaderboard-text',
settings=(
entry_position_colour,
entry_position_highlight_colour,
entry_name_colour,
entry_hours_colour
)
)
entry_bg_group = SettingGroup(
_p('skinsettings|page:leaderboard|grp:entry_bg', "Entry Background"),
description=_p(
'skinsettings|page:leaderboard|grp:entry_bg|desc',
"Background colours of the leaderboard entries."
),
custom_id='leaderboard-bg',
settings=(
entry_bg_colour,
entry_bg_highlight_colour
)
)
misc_group = SettingGroup(
_p('skinsettings|page:leaderboard|grp:misc', "Miscellaneous"),
description=_p(
'skinsettings|page:leaderboard|grp:misc|desc',
"Other miscellaneous colour settings."
),
custom_id='leaderboard-misc',
settings=(
header_text_colour,
subheader_colour
)
)
base_skin = SkinSetting(
card=LeaderboardCard,
property_name='base_skin_id',
display_name=_p(
'skinsettings|page:leaderboard|set:base_skin|display_name',
'Skin'
),
description=_p(
'skinsettings|page:leaderboard|set:base_skin|desc',
"Select a Skin Preset."
)
)
base_skin_group = SettingGroup(
_p('skinsettings|page:leaderboard|grp:base_skin', "Leaderboard Skin"),
description=_p(
'skinsettings|page:leaderboard|grp:base_skin|desc',
"Asset pack and default values for the Leaderboard."
),
custom_id='leaderboard-skin',
settings=(base_skin,),
ungrouped=True
)
leaderboard_page.groups = [
base_skin_group,
top_colour_group,
entry_text_group,
entry_bg_group,
misc_group
]

View File

@@ -0,0 +1,376 @@
from gui.cards import MonthlyStatsCard
from ... import babel
from ..skinsetting import ColourSetting, SkinSetting, ColoursSetting
from ..layout import Page, SettingGroup
_p = babel._p
""""
title_colour
[this_month_colour, last_month_colour]
[stats_key_colour, stats_value_colour]
footer_colour
top_hours_colour
top_hours_bg_colour
top_date_colour
top_line_colour
top_this_colour
top_this_hours_colour
top_last_colour
top_last_hours_colour
weekday_background_colour
weekday_colour
month_background_colour
month_colour
"""
monthly_page = Page(
display_name=_p('skinsettings|page:monthly|display_name', "Monthly Statistics"),
editing_description=_p(
'skinsettings|page:monthly|edit_desc',
"Options for the monthly statistis card."
),
preview_description=None,
visible_in_preview=True,
render_card=MonthlyStatsCard
)
title_colour = ColourSetting(
card=MonthlyStatsCard,
property_name='title_colour',
display_name=_p(
'skinsettings|page:monthly|set:title_colour|display_name',
'Title'
),
description=_p(
'skinsettings|page:monthly|set:title_colour|desc',
"Colour of the card title."
)
)
top_hours_colour = ColourSetting(
card=MonthlyStatsCard,
property_name='top_hours_colour',
display_name=_p(
'skinsettings|page:monthly|set:top_hours_colour|display_name',
'Hours'
),
description=_p(
'skinsettings|page:monthly|set:top_hours_colour|desc',
"Hour axis labels."
)
)
top_hours_bg_colour = ColourSetting(
card=MonthlyStatsCard,
property_name='top_hours_bg_colour',
display_name=_p(
'skinsettings|page:monthly|set:top_hours_bg_colour|display_name',
'Hours Bg'
),
description=_p(
'skinsettings|page:monthly|set:top_hours_bg_colour|desc',
"Hour axis label background."
)
)
top_line_colour = ColourSetting(
card=MonthlyStatsCard,
property_name='top_line_colour',
display_name=_p(
'skinsettings|page:monthly|set:top_line_colour|display_name',
'Lines'
),
description=_p(
'skinsettings|page:monthly|set:top_line_colour|desc',
"Horizontal graph lines."
)
)
top_date_colour = ColourSetting(
card=MonthlyStatsCard,
property_name='top_date_colour',
display_name=_p(
'skinsettings|page:monthly|set:top_date_colour|display_name',
'Days'
),
description=_p(
'skinsettings|page:monthly|set:top_date_colour|desc',
"Day axis labels."
)
)
top_this_colour = ColourSetting(
card=MonthlyStatsCard,
property_name='top_this_colour',
display_name=_p(
'skinsettings|page:monthly|set:top_this_colour|display_name',
'This Month Bar'
),
description=_p(
'skinsettings|page:monthly|set:top_this_colour|desc',
"This month bar fill colour."
)
)
top_last_colour = ColourSetting(
card=MonthlyStatsCard,
property_name='top_last_colour',
display_name=_p(
'skinsettings|page:monthly|set:top_last_colour|display_name',
'Last Month Bar'
),
description=_p(
'skinsettings|page:monthly|set:top_last_colour|desc',
"Last month bar fill colour."
)
)
top_this_hours_colour = ColourSetting(
card=MonthlyStatsCard,
property_name='top_this_hours_colour',
display_name=_p(
'skinsettings|page:monthly|set:top_this_hours_colour|display_name',
'This Month Hours'
),
description=_p(
'skinsettings|page:monthly|set:top_this_hours_colour|desc',
"This month hour text."
)
)
top_last_hours_colour = ColourSetting(
card=MonthlyStatsCard,
property_name='top_last_hours_colour',
display_name=_p(
'skinsettings|page:monthly|set:top_last_hours_colour|display_name',
'Last Month Hours'
),
description=_p(
'skinsettings|page:monthly|set:top_last_hours_colour|desc',
"Last month hour text."
)
)
this_month_colour = ColourSetting(
card=MonthlyStatsCard,
property_name='this_month_colour',
display_name=_p(
'skinsettings|page:monthly|set:this_month_colour|display_name',
'This Month Legend'
),
description=_p(
'skinsettings|page:monthly|set:this_month_colour|desc',
"This month legend text."
)
)
last_month_colour = ColourSetting(
card=MonthlyStatsCard,
property_name='last_month_colour',
display_name=_p(
'skinsettings|page:monthly|set:last_month_colour|display_name',
'Last Month Legend'
),
description=_p(
'skinsettings|page:monthly|set:last_month_colour|desc',
"Last month legend text."
)
)
legend_colour = ColoursSetting(
this_month_colour,
last_month_colour,
display_name=_p(
'skinsettings|page:monthly|set:legend_colour|display_name',
'Legend'
),
description=_p(
'skinsettings|page:monthly|set:legend_colour|desc',
"Legend text colour."
)
)
weekday_background_colour = ColourSetting(
card=MonthlyStatsCard,
property_name='weekday_background_colour',
display_name=_p(
'skinsettings|page:monthly|set:weekday_background_colour|display_name',
'Weekday Bg'
),
description=_p(
'skinsettings|page:monthly|set:weekday_background_colour|desc',
"Weekday axis background colour."
)
)
weekday_colour = ColourSetting(
card=MonthlyStatsCard,
property_name='weekday_colour',
display_name=_p(
'skinsettings|page:monthly|set:weekday_colour|display_name',
'Weekdays'
),
description=_p(
'skinsettings|page:monthly|set:weekday_colour|desc',
"Weekday axis labels."
)
)
month_background_colour = ColourSetting(
card=MonthlyStatsCard,
property_name='month_background_colour',
display_name=_p(
'skinsettings|page:monthly|set:month_background_colour|display_name',
'Month Bg'
),
description=_p(
'skinsettings|page:monthly|set:month_background_colour|desc',
"Month axis label backgrounds."
)
)
month_colour = ColourSetting(
card=MonthlyStatsCard,
property_name='month_colour',
display_name=_p(
'skinsettings|page:monthly|set:month_colour|display_name',
'Months'
),
description=_p(
'skinsettings|page:monthly|set:month_colour|desc',
"Month axis label text."
)
)
stats_key_colour = ColourSetting(
card=MonthlyStatsCard,
property_name='stats_key_colour',
display_name=_p(
'skinsettings|page:monthly|set:stats_key_colour|display_name',
'Stat Names'
),
description=_p(
'skinsettings|page:monthly|set:stats_key_colour|desc',
""
)
)
stats_value_colour = ColourSetting(
card=MonthlyStatsCard,
property_name='stats_value_colour',
display_name=_p(
'skinsettings|page:monthly|set:stats_value_colour|display_name',
'Stat Values'
),
description=_p(
'skinsettings|page:monthly|set:stats_value_colour|desc',
""
)
)
statistics_colour = ColoursSetting(
stats_key_colour,
stats_value_colour,
display_name=_p(
'skinsettings|page:monthly|set:statistics_colour|display_name',
'Statistics'
),
description=_p(
'skinsettings|page:monthly|set:statistics_colour|desc',
"Summary Statistics"
)
)
footer_colour = ColourSetting(
card=MonthlyStatsCard,
property_name='footer_colour',
display_name=_p(
'skinsettings|page:monthly|set:footer_colour|display_name',
'Footer'
),
description=_p(
'skinsettings|page:monthly|set:footer_colour|desc',
"Footer text colour."
)
)
top_graph_group = SettingGroup(
_p('skinsettings|page:monthly|grp:top_graph', "Top Graph"),
description=_p(
'skinsettings|page:monthly|grp:top_graph|desc',
"Customise the axis and style of the top graph."
),
custom_id='monthly-top',
settings=(
top_hours_colour,
top_hours_bg_colour,
top_date_colour,
top_line_colour
)
)
top_hours_group = SettingGroup(
_p('skinsettings|page:monthly|grp:top_hours', "Hours"),
description=_p(
'skinsettings|page:monthly|grp:top_hours|desc',
"Customise the colour of this week and last week."
),
custom_id='monthly-hours',
settings=(
top_this_colour,
top_this_hours_colour,
top_last_colour,
top_last_hours_colour
)
)
bottom_graph_group = SettingGroup(
_p('skinsettings|page:monthly|grp:bottom_graph', "Heatmap"),
description=_p(
'skinsettings|page:monthly|grp:bottom_graph|desc',
"Customise the axis and style of the heatmap."
),
custom_id='monthly-heatmap',
settings=(
weekday_background_colour,
weekday_colour,
month_background_colour,
month_colour
)
)
misc_group = SettingGroup(
_p('skinsettings|page:monthly|grp:misc', "Miscellaneous"),
description=_p(
'skinsettings|page:monthly|grp:misc|desc',
"Miscellaneous colour options."
),
custom_id='monthly-misc',
settings=(
title_colour,
legend_colour,
statistics_colour,
footer_colour
)
)
base_skin = SkinSetting(
card=MonthlyStatsCard,
property_name='base_skin_id',
display_name=_p(
'skinsettings|page:monthly|set:base_skin|display_name',
'Skin'
),
description=_p(
'skinsettings|page:monthly|set:base_skin|desc',
"Select a Skin Preset."
)
)
base_skin_group = SettingGroup(
_p('skinsettings|page:monthly|grp:base_skin', "Monthly Stats Skin"),
description=_p(
'skinsettings|page:monthly|grp:base_skin|desc',
"Asset pack and default values for the Monthly Statistics."
),
custom_id='monthly-skin',
settings=(base_skin,),
ungrouped=True
)
monthly_page.groups = [
base_skin_group,
top_graph_group,
top_hours_group,
bottom_graph_group,
misc_group
]

View File

@@ -0,0 +1,436 @@
from gui.cards import MonthlyGoalCard
from ... import babel
from ..skinsetting import ColourSetting, SkinSetting, ColoursSetting
from ..layout import Page, SettingGroup
_p = babel._p
"""
mini_profile_name_colour
mini_profile_badge_colour
mini_profile_badge_text_colour
mini_profile_discrim_colour
title_colour
footer_colour
progress_bg_colour
progress_colour
[attendance_rate_colour, task_count_colour, studied_hour_colour]
[attendance_colour, task_done_colour, studied_text_colour, task_goal_colour]
task_goal_number_colour
task_header_colour
task_done_number_colour
task_undone_number_colour
task_done_text_colour
task_undone_text_colour
"""
monthly_goal_page = Page(
display_name=_p('skinsettings|page:monthly_goal|display_name', "Monthly Goals"),
editing_description=_p(
'skinsettings|page:monthly_goal|edit_desc',
"Options for the monthly goal card."
),
preview_description=None,
visible_in_preview=True,
render_card=MonthlyGoalCard
)
title_colour = ColourSetting(
card=MonthlyGoalCard,
property_name='title_colour',
display_name=_p(
'skinsettings|page:monthly_goal|set:title_colour|display_name',
"Title"
),
description=_p(
'skinsettings|page:monthly_goal|set:title_colour|desc',
""
)
)
progress_bg_colour = ColourSetting(
card=MonthlyGoalCard,
property_name='progress_bg_colour',
display_name=_p(
'skinsettings|page:monthly_goal|set:progress_bg_colour|display_name',
"Bar Bg"
),
description=_p(
'skinsettings|page:monthly_goal|set:progress_bg_colour|desc',
""
)
)
progress_colour = ColourSetting(
card=MonthlyGoalCard,
property_name='progress_colour',
display_name=_p(
'skinsettings|page:monthly_goal|set:progress_colour|display_name',
"Bar Fg"
),
description=_p(
'skinsettings|page:monthly_goal|set:progress_colour|desc',
""
)
)
attendance_rate_colour = ColourSetting(
card=MonthlyGoalCard,
property_name='attendance_rate_colour',
display_name=_p(
'skinsettings|page:monthly_goal|set:attendance_rate_colour|display_name',
""
),
description=_p(
'skinsettings|page:monthly_goal|set:attendance_rate_colour|desc',
""
)
)
attendance_colour = ColourSetting(
card=MonthlyGoalCard,
property_name='attendance_colour',
display_name=_p(
'skinsettings|page:monthly_goal|set:attendance_colour|display_name',
""
),
description=_p(
'skinsettings|page:monthly_goal|set:attendance_colour|desc',
""
)
)
task_count_colour = ColourSetting(
card=MonthlyGoalCard,
property_name='task_count_colour',
display_name=_p(
'skinsettings|page:monthly_goal|set:task_count_colour|display_name',
""
),
description=_p(
'skinsettings|page:monthly_goal|set:task_count_colour|desc',
""
)
)
task_done_colour = ColourSetting(
card=MonthlyGoalCard,
property_name='task_done_colour',
display_name=_p(
'skinsettings|page:monthly_goal|set:task_done_colour|display_name',
""
),
description=_p(
'skinsettings|page:monthly_goal|set:task_done_colour|desc',
""
)
)
task_goal_colour = ColourSetting(
card=MonthlyGoalCard,
property_name='task_goal_colour',
display_name=_p(
'skinsettings|page:monthly_goal|set:task_goal_colour|display_name',
""
),
description=_p(
'skinsettings|page:monthly_goal|set:task_goal_colour|desc',
""
)
)
task_goal_number_colour = ColourSetting(
card=MonthlyGoalCard,
property_name='task_goal_number_colour',
display_name=_p(
'skinsettings|page:monthly_goal|set:task_goal_number_colour|display_name',
"Task Goal"
),
description=_p(
'skinsettings|page:monthly_goal|set:task_goal_number_colour|desc',
""
)
)
studied_text_colour = ColourSetting(
card=MonthlyGoalCard,
property_name='studied_text_colour',
display_name=_p(
'skinsettings|page:monthly_goal|set:studied_text_colour|display_name',
""
),
description=_p(
'skinsettings|page:monthly_goal|set:studied_text_colour|desc',
""
)
)
studied_hour_colour = ColourSetting(
card=MonthlyGoalCard,
property_name='studied_hour_colour',
display_name=_p(
'skinsettings|page:monthly_goal|set:studied_hour_colour|display_name',
""
),
description=_p(
'skinsettings|page:monthly_goal|set:studied_hour_colour|desc',
""
)
)
text_highlight_colour = ColoursSetting(
attendance_rate_colour,
task_count_colour,
studied_hour_colour,
display_name=_p(
'skinsettings|page:monthly_goal|set:text_highlight_colour|display_name',
"Highlight Text"
),
description=_p(
'skinsettings|page:monthly_goal|set:text_highlight_colour|desc',
"Progress text colour."
)
)
text_colour = ColoursSetting(
attendance_colour,
task_done_colour,
studied_text_colour,
task_goal_colour,
display_name=_p(
'skinsettings|page:monthly_goal|set:text_colour|display_name',
"Text"
),
description=_p(
'skinsettings|page:monthly_goal|set:text_colour|desc',
"Achievement description text colour."
)
)
task_header_colour = ColourSetting(
card=MonthlyGoalCard,
property_name='task_header_colour',
display_name=_p(
'skinsettings|page:monthly_goal|set:task_header_colour|display_name',
"Task Header"
),
description=_p(
'skinsettings|page:monthly_goal|set:task_header_colour|desc',
""
)
)
task_done_number_colour = ColourSetting(
card=MonthlyGoalCard,
property_name='task_done_number_colour',
display_name=_p(
'skinsettings|page:monthly_goal|set:task_done_number_colour|display_name',
"Checked Number"
),
description=_p(
'skinsettings|page:monthly_goal|set:task_done_number_colour|desc',
""
)
)
task_undone_number_colour = ColourSetting(
card=MonthlyGoalCard,
property_name='task_undone_number_colour',
display_name=_p(
'skinsettings|page:monthly_goal|set:task_undone_number_colour|display_name',
"Unchecked Number"
),
description=_p(
'skinsettings|page:monthly_goal|set:task_undone_number_colour|desc',
""
)
)
task_done_text_colour = ColourSetting(
card=MonthlyGoalCard,
property_name='task_done_text_colour',
display_name=_p(
'skinsettings|page:monthly_goal|set:task_done_text_colour|display_name',
"Checked Text"
),
description=_p(
'skinsettings|page:monthly_goal|set:task_done_text_colour|desc',
""
)
)
task_undone_text_colour = ColourSetting(
card=MonthlyGoalCard,
property_name='task_undone_text_colour',
display_name=_p(
'skinsettings|page:monthly_goal|set:task_undone_text_colour|display_name',
"Unchecked Text"
),
description=_p(
'skinsettings|page:monthly_goal|set:task_undone_text_colour|desc',
""
)
)
footer_colour = ColourSetting(
card=MonthlyGoalCard,
property_name='footer_colour',
display_name=_p(
'skinsettings|page:monthly_goal|set:footer_colour|display_name',
"Footer"
),
description=_p(
'skinsettings|page:monthly_goal|set:footer_colour|desc',
""
)
)
mini_profile_badge_colour = ColourSetting(
card=MonthlyGoalCard,
property_name='mini_profile_badge_colour',
display_name=_p(
'skinsettings|page:monthly_goal|set:mini_profile_badge_colour|display_name',
'Badge Background'
),
description=_p(
'skinsettings|page:monthly_goal|set:mini_profile_badge_colour|desc',
"Mini-profile badge background colour."
)
)
mini_profile_badge_text_colour = ColourSetting(
card=MonthlyGoalCard,
property_name='mini_profile_badge_text_colour',
display_name=_p(
'skinsettings|page:monthly_goal|set:mini_profile_badge_text_colour|display_name',
'Badge Text'
),
description=_p(
'skinsettings|page:monthly_goal|set:mini_profile_badge_text_colour|desc',
""
)
)
mini_profile_name_colour = ColourSetting(
card=MonthlyGoalCard,
property_name='mini_profile_name_colour',
display_name=_p(
'skinsettings|page:monthly_goal|set:mini_profile_name_colour|display_name',
'Username'
),
description=_p(
'skinsettings|page:monthly_goal|set:mini_profile_name_colour|desc',
"Mini-profile username colour."
)
)
mini_profile_discrim_colour = ColourSetting(
card=MonthlyGoalCard,
property_name='mini_profile_discrim_colour',
display_name=_p(
'skinsettings|page:monthly_goal|set:mini_profile_discrim_colour|display_name',
'Discriminator'
),
description=_p(
'skinsettings|page:monthly_goal|set:mini_profile_discrim_colour|desc',
"Mini-profile discriminator colour."
)
)
mini_profile_group = SettingGroup(
_p('skinsettings|page:monthly_goal|grp:mini_profile', "Profile"),
description=_p(
'skinsettings|page:monthly_goal|grp:mini_profile|desc',
"Customise the mini-profile shown above the goals."
),
custom_id='monthlygoal-profile',
settings=(
mini_profile_name_colour,
mini_profile_discrim_colour,
mini_profile_badge_colour,
mini_profile_badge_text_colour,
)
)
misc_group = SettingGroup(
_p('skinsettings|page:monthly_goal|grp:misc', "Miscellaneous"),
description=_p(
'skinsettings|page:monthly_goal|grp:misc|desc',
"Other miscellaneous colours."
),
custom_id='monthlygoal-misc',
settings=(
title_colour,
footer_colour,
)
)
task_colour_group = SettingGroup(
_p('skinsettings|page:monthly_goal|grp:task_colour', "Task colours"),
description=_p(
'skinsettings|page:monthly_goal|grp:task_colour|desc',
"Text and number colours for (in)complete goals."
),
custom_id='monthlygoal-tasks',
settings=(
task_undone_number_colour,
task_done_number_colour,
task_undone_text_colour,
task_done_text_colour,
)
)
progress_colour_group = SettingGroup(
_p('skinsettings|page:monthly_goal|grp:progress_colour', "Progress Colours"),
description=_p(
'skinsettings|page:monthly_goal|grp:progress_colour|desc',
"Customise colours for the monthly achievement progress."
),
custom_id='monthlygoal-progress',
settings=(
progress_bg_colour,
progress_colour,
text_colour,
text_highlight_colour,
task_goal_number_colour
)
)
base_skin = SkinSetting(
card=MonthlyGoalCard,
property_name='base_skin_id',
display_name=_p(
'skinsettings|page:monthly_goal|set:base_skin|display_name',
'Skin'
),
description=_p(
'skinsettings|page:monthly_goal|set:base_skin|desc',
"Select a Skin Preset."
)
)
base_skin_group = SettingGroup(
_p('skinsettings|page:monthly_goal|grp:base_skin', "Monthly Goals Skin"),
description=_p(
'skinsettings|page:monthly_goal|grp:base_skin|desc',
"Asset pack and default values for the Monthly Goals."
),
custom_id='monthlygoals-skin',
settings=(base_skin,),
ungrouped=True
)
monthly_goal_page.groups = [
base_skin_group,
mini_profile_group,
misc_group,
progress_colour_group,
task_colour_group
]

View File

@@ -0,0 +1,266 @@
from gui.cards import ProfileCard
from ... import babel
from ..skinsetting import ColourSetting, SkinSetting, ColoursSetting
from ..layout import Page, SettingGroup
_p = babel._p
profile_page = Page(
display_name=_p('skinsettings|page:profile|display_name', "Member Profile"),
editing_description=_p(
'skinsettings|page:profile|edit_desc',
"Options for the member profile card."
),
preview_description=None,
visible_in_preview=True,
render_card=ProfileCard
)
header_colour_1 = ColourSetting(
card=ProfileCard,
property_name='header_colour_1',
display_name=_p(
'skinsettings|page:profile|set:header_colour_1|display_name',
'Username'
),
description=_p(
'skinsettings|page:profile|set:header_colour_1|desc',
"Text colour of the profile username."
)
)
header_colour_2 = ColourSetting(
card=ProfileCard,
property_name='header_colour_2',
display_name=_p(
'skinsettings|page:profile|set:header_colour_2|display_name',
'Discriminator'
),
description=_p(
'skinsettings|page:profile|set:header_colour_2|desc',
"Text colour of the profile dscriminator."
)
)
counter_bg_colour = ColourSetting(
card=ProfileCard,
property_name='counter_bg_colour',
display_name=_p(
'skinsettings|page:profile|set:counter_bg_colour|display_name',
'Background'
),
description=_p(
'skinsettings|page:profile|set:counter_bg_colour|desc',
"Colour of the coin/gem/gift backgrounds."
)
)
counter_colour = ColourSetting(
card=ProfileCard,
property_name='counter_colour',
display_name=_p(
'skinsettings|page:profile|set:counter_colour|display_name',
'Text'
),
description=_p(
'skinsettings|page:profile|set:counter_colour|desc',
"Colour of the coin/gem/gift text."
)
)
subheader_colour = ColourSetting(
card=ProfileCard,
property_name='subheader_colour',
display_name=_p(
'skinsettings|page:profile|set:subheader_colour|display_name',
'Column Header'
),
description=_p(
'skinsettings|page:profile|set:subheader_colour|desc',
"Colour of the Profile/Achievements header."
)
)
badge_text_colour = ColourSetting(
card=ProfileCard,
property_name='badge_text_colour',
display_name=_p(
'skinsettings|page:profile|set:badge_text_colour|display_name',
'Badge Text'
),
description=_p(
'skinsettings|page:profile|set:badge_text_colour|desc',
"Colour of the profile badge text."
)
)
badge_blob_colour = ColourSetting(
card=ProfileCard,
property_name='badge_blob_colour',
display_name=_p(
'skinsettings|page:profile|set:badge_blob_colour|display_name',
'Background'
),
description=_p(
'skinsettings|page:profile|set:badge_blob_colour|desc',
"Colour of the profile badge background."
)
)
rank_name_colour = ColourSetting(
card=ProfileCard,
property_name='rank_name_colour',
display_name=_p(
'skinsettings|page:profile|set:rank_name_colour|display_name',
'Current Rank'
),
description=_p(
'skinsettings|page:profile|set:rank_name_colour|desc',
"Colour of the current study rank name."
)
)
rank_hours_colour = ColourSetting(
card=ProfileCard,
property_name='rank_hours_colour',
display_name=_p(
'skinsettings|page:profile|set:rank_hours_colour|display_name',
'Required Hours'
),
description=_p(
'skinsettings|page:profile|set:rank_hours_colour|desc',
"Colour of the study rank hour range."
)
)
bar_full_colour = ColourSetting(
card=ProfileCard,
property_name='bar_full_colour',
display_name=_p(
'skinsettings|page:profile|set:bar_full_colour|display_name',
'Bar Full'
),
description=_p(
'skinsettings|page:profile|set:bar_full_colour|desc',
"Foreground progress bar colour."
)
)
bar_empty_colour = ColourSetting(
card=ProfileCard,
property_name='bar_empty_colour',
display_name=_p(
'skinsettings|page:profile|set:bar_empty_colour|display_name',
'Bar Empty'
),
description=_p(
'skinsettings|page:profile|set:bar_empty_colour|desc',
"Background progress bar colour."
)
)
next_rank_colour = ColourSetting(
card=ProfileCard,
property_name='next_rank_colour',
display_name=_p(
'skinsettings|page:profile|set:next_rank_colour|display_name',
'Next Rank'
),
description=_p(
'skinsettings|page:profile|set:next_rank_colour|desc',
"Colour of the next rank name and hours."
)
)
title_colour_group = SettingGroup(
_p('skinsettings|page:profile|grp:title_colour', "Title Colours"),
description=_p(
'skinsettings|page:profile|grp:title_colour|desc',
"Header and suheader text colours."
),
custom_id='profile-titles',
settings=(
header_colour_1,
header_colour_2,
subheader_colour
),
)
badge_colour_group = SettingGroup(
_p('skinsettings|page:profile|grp:badge_colour', "Profile Badge Colours"),
description=_p(
'skinsettings|page:profile|grp:badge_colour|desc',
"Text and background for the profile badges."
),
custom_id='profile-badges',
settings=(
badge_text_colour,
badge_blob_colour
),
)
counter_colour_group = SettingGroup(
_p('skinsettings|page:profile|grp:counter_colour', "Counter Colours"),
description=_p(
'skinsettings|page:profile|grp:counter_colour|desc',
"Text and background for the coin/gem/gift counters."
),
custom_id='profile-counters',
settings=(
counter_colour,
counter_bg_colour
),
)
rank_colour_group = SettingGroup(
_p('skinsettings|page:profile|grp:rank_colour', "Progress Bar"),
description=_p(
'skinsettings|page:profile|grp:rank_colour|desc',
"Colours for the study badge/rank progress bar."
),
custom_id='profile-progress',
settings=(
rank_name_colour,
rank_hours_colour,
next_rank_colour,
bar_full_colour,
bar_empty_colour
),
)
base_skin = SkinSetting(
card=ProfileCard,
property_name='base_skin_id',
display_name=_p(
'skinsettings|page:profile|set:base_skin|display_name',
'Skin'
),
description=_p(
'skinsettings|page:profile|set:base_skin|desc',
"Select a Skin Preset."
)
)
base_skin_group = SettingGroup(
_p('skinsettings|page:profile|grp:base_skin', "Profile Skin"),
description=_p(
'skinsettings|page:profile|grp:base_skin|desc',
"Asset pack and default values for this card."
),
custom_id='profile-skin',
settings=(base_skin,),
ungrouped=True
)
profile_page.groups = [
base_skin_group,
title_colour_group,
badge_colour_group,
rank_colour_group,
counter_colour_group,
]

View File

@@ -0,0 +1,211 @@
from gui.cards import StatsCard
from ... import babel
from ..skinsetting import ColourSetting, SkinSetting, ColoursSetting
from ..layout import Page, SettingGroup
_p = babel._p
stats_page = Page(
display_name=_p('skinsettings|page:stats|display_name', "Statistics"),
editing_description=_p(
'skinsettings|page:stats|edit_desc',
"Options for the member statistics card."
),
preview_description=None,
visible_in_preview=True,
render_card=StatsCard
)
header_colour = ColourSetting(
card=StatsCard,
property_name='header_colour',
display_name=_p(
'skinsettings|page:stats|set:header_colour|display_name',
'Titles'
),
description=_p(
'skinsettings|page:stats|set:header_colour|desc',
"Top header text colour."
)
)
stats_subheader_colour = ColourSetting(
card=StatsCard,
property_name='stats_subheader_colour',
display_name=_p(
'skinsettings|page:stats|set:stats_subheader_colour|display_name',
'Sections'
),
description=_p(
'skinsettings|page:stats|set:stats_subheader_colour|desc',
"Text colour of the Statistics section titles."
)
)
stats_text_colour = ColourSetting(
card=StatsCard,
property_name='stats_text_colour',
display_name=_p(
'skinsettings|page:stats|set:stats_text_colour|display_name',
'Statistics'
),
description=_p(
'skinsettings|page:stats|set:stats_text_colour|desc',
"Text colour of the Statistics section bodies."
)
)
col2_date_colour = ColourSetting(
card=StatsCard,
property_name='col2_date_colour',
display_name=_p(
'skinsettings|page:stats|set:col2_date_colour|display_name',
'Date'
),
description=_p(
'skinsettings|page:stats|set:col2_date_colour|desc',
"Colour of the current month and year."
)
)
col2_hours_colour = ColourSetting(
card=StatsCard,
property_name='col2_hours_colour',
display_name=_p(
'skinsettings|page:stats|set:col2_hours_colour|display_name',
'Hours'
),
description=_p(
'skinsettings|page:stats|set:col2_hours_colour|desc',
"Colour of the monthly hour total."
)
)
text_colour_group = SettingGroup(
_p('skinsettings|page:stats|grp:text_colour', "Text Colours"),
description=_p(
'skinsettings|page:stats|grp:text_colour|desc',
"Header colours and statistics text colours."
),
custom_id='stats-text',
settings=(
header_colour,
stats_subheader_colour,
stats_text_colour,
col2_date_colour,
col2_hours_colour
)
)
cal_weekday_colour = ColourSetting(
card=StatsCard,
property_name='cal_weekday_colour',
display_name=_p(
'skinsettings|page:stats|set:cal_weekday_colour|display_name',
'Weekdays'
),
description=_p(
'skinsettings|page:stats|set:cal_weekday_colour|desc',
"Colour of the week day letters."
),
)
cal_number_colour = ColourSetting(
card=StatsCard,
property_name='cal_number_colour',
display_name=_p(
'skinsettings|page:stats|set:cal_number_colour|display_name',
'Numbers'
),
description=_p(
'skinsettings|page:stats|set:cal_number_colour|desc',
"General calender day colour."
),
)
cal_number_end_colour = ColourSetting(
card=StatsCard,
property_name='cal_number_end_colour',
display_name=_p(
'skinsettings|page:stats|set:cal_number_end_colour|display_name',
'Streak Ends'
),
description=_p(
'skinsettings|page:stats|set:cal_number_end_colour|desc',
"Day colour where streaks start or end."
),
)
cal_streak_middle_colour = ColourSetting(
card=StatsCard,
property_name='cal_streak_middle_colour',
display_name=_p(
'skinsettings|page:stats|set:cal_streak_middle_colour|display_name',
'Streak BG'
),
description=_p(
'skinsettings|page:stats|set:cal_streak_middle_colour|desc',
"Background colour on streak days."
),
)
cal_streak_end_colour = ColourSetting(
card=StatsCard,
property_name='cal_streak_end_colour',
display_name=_p(
'skinsettings|page:stats|set:cal_streak_end_colour|display_name',
'Streak End BG'
),
description=_p(
'skinsettings|page:stats|set:cal_streak_end_colour|desc',
"Background colour where streaks start/end."
),
)
calender_colour_group = SettingGroup(
_p('skinsettings|page:stats|grp:calender_colour', "Calender Colours"),
description=_p(
'skinsettings|page:stats|grp:calender_colour|desc',
"Number and streak colours for the current calender."
),
custom_id='stats-cal',
settings=(
cal_weekday_colour,
cal_number_colour,
cal_number_end_colour,
cal_streak_middle_colour,
cal_streak_end_colour
)
)
base_skin = SkinSetting(
card=StatsCard,
property_name='base_skin_id',
display_name=_p(
'skinsettings|page:stats|set:base_skin|display_name',
'Skin'
),
description=_p(
'skinsettings|page:stats|set:base_skin|desc',
"Select a Skin Preset."
)
)
base_skin_group = SettingGroup(
_p('skinsettings|page:stats|grp:base_skin', "Statistics Skin"),
description=_p(
'skinsettings|page:stats|grp:base_skin|desc',
"Asset pack and default values for this card."
),
custom_id='stats-skin',
settings=(base_skin,),
ungrouped=True
)
stats_page.groups = [base_skin_group, text_colour_group, calender_colour_group]

View File

@@ -0,0 +1,89 @@
from gui.cards import ProfileCard
from ... import babel
from ..skinsetting import ColourSetting, SkinSetting, ColoursSetting
from ..layout import Page, SettingGroup
from . import stats, profile
_p = babel._p
summary_page = Page(
display_name=_p('skinsettings|page:summary|display_name', "Setting Summary"),
editing_description=_p(
'skinsettings|page:summary|edit_desc',
"Simple setup for creating a unified interface theme."
),
preview_description=_p(
'skinsettings|page:summary|preview_desc',
"Summary of common settings across the entire interface."
),
visible_in_preview=True,
render_card=ProfileCard
)
name_colours = ColoursSetting(
profile.header_colour_1,
display_name=_p(
'skinsettings|page:summary|set:name_colours|display_name',
"username colour"
),
description=_p(
'skinsettings|page:summary|set:name_colours|desc',
"Author username colour."
)
)
discrim_colours = ColoursSetting(
profile.header_colour_2,
display_name=_p(
'skinsettings|page:summary|set:discrim_colours|display_name',
"discrim colour"
),
description=_p(
'skinsettings|page:summary|set:discrim_colours|desc',
"Author discriminator colour."
)
)
subheader_colour = ColoursSetting(
stats.header_colour,
profile.subheader_colour,
display_name=_p(
'skinsettings|page:summary|set:subheader_colour|display_name',
"subheadings"
),
description=_p(
'skinsettings|page:summary|set:subheader_colour|desc',
"Colour of subheadings and column headings."
)
)
header_colour_group = SettingGroup(
_p('skinsettings|page:summary|grp:header_colour', "Title Colours"),
description=_p(
'skinsettings|page:summary|grp:header_colour|desc',
"Title and header text colours."
),
custom_id='shared-titles',
settings=(
subheader_colour,
)
)
profile_colour_group = SettingGroup(
_p('skinsettings|page:summary|grp:profile_colour', "Profile Colours"),
description=_p(
'skinsettings|page:summary|grp:profile_colour|desc',
"Profile elements shared across various cards."
),
custom_id='shared-profile',
settings=(
name_colours,
discrim_colours
)
)
summary_page.groups = [header_colour_group, profile_colour_group]

View File

@@ -0,0 +1,350 @@
from gui.cards import WeeklyStatsCard
from ... import babel
from ..skinsetting import ColourSetting, SkinSetting, ColoursSetting
from ..layout import Page, SettingGroup
_p = babel._p
weekly_page = Page(
display_name=_p('skinsettings|page:weekly|display_name', "Weekly Statistics"),
editing_description=_p(
'skinsettings|page:weekly|edit_desc',
"Options for the weekly statistis card."
),
preview_description=None,
visible_in_preview=True,
render_card=WeeklyStatsCard
)
title_colour = ColourSetting(
card=WeeklyStatsCard,
property_name='title_colour',
display_name=_p(
'skinsettings|page:weekly|set:title_colour|display_name',
'Title'
),
description=_p(
'skinsettings|page:weekly|set:title_colour|desc',
"Colour of the card title."
)
)
top_hours_colour = ColourSetting(
card=WeeklyStatsCard,
property_name='top_hours_colour',
display_name=_p(
'skinsettings|page:weekly|set:top_hours_colour|display_name',
'Hours'
),
description=_p(
'skinsettings|page:weekly|set:top_hours_colour|desc',
"Hours axis labels."
)
)
top_hours_bg_colour = ColourSetting(
card=WeeklyStatsCard,
property_name='top_hours_bg_colour',
display_name=_p(
'skinsettings|page:weekly|set:top_hours_bg_colour|display_name',
'Hour Bg'
),
description=_p(
'skinsettings|page:weekly|set:top_hours_bg_colour|desc',
"Hours axis label background."
)
)
top_line_colour = ColourSetting(
card=WeeklyStatsCard,
property_name='top_line_colour',
display_name=_p(
'skinsettings|page:weekly|set:top_line_colour|display_name',
'Lines'
),
description=_p(
'skinsettings|page:weekly|set:top_line_colour|desc',
"Horizontal graph lines."
)
)
top_weekday_colour = ColourSetting(
card=WeeklyStatsCard,
property_name='top_weekday_colour',
display_name=_p(
'skinsettings|page:weekly|set:top_weekday_colour|display_name',
'Weekdays'
),
description=_p(
'skinsettings|page:weekly|set:top_weekday_colour|desc',
"Weekday axis labels."
)
)
top_date_colour = ColourSetting(
card=WeeklyStatsCard,
property_name='top_date_colour',
display_name=_p(
'skinsettings|page:weekly|set:top_date_colour|display_name',
'Dates'
),
description=_p(
'skinsettings|page:weekly|set:top_date_colour|desc',
"Weekday date axis labels."
)
)
top_this_colour = ColourSetting(
card=WeeklyStatsCard,
property_name='top_this_colour',
display_name=_p(
'skinsettings|page:weekly|set:top_this_colour|display_name',
'This Week'
),
description=_p(
'skinsettings|page:weekly|set:top_this_colour|desc',
"This week bar fill colour."
)
)
top_last_colour = ColourSetting(
card=WeeklyStatsCard,
property_name='top_last_colour',
display_name=_p(
'skinsettings|page:weekly|set:top_last_colour|display_name',
'Last Week'
),
description=_p(
'skinsettings|page:weekly|set:top_last_colour|desc',
"Last week bar fill colour."
)
)
btm_weekday_colour = ColourSetting(
card=WeeklyStatsCard,
property_name='btm_weekday_colour',
display_name=_p(
'skinsettings|page:weekly|set:btm_weekday_colour|display_name',
'Weekdays'
),
description=_p(
'skinsettings|page:weekly|set:btm_weekday_colour|desc',
"Weekday axis labels."
)
)
btm_weekly_background_colour = ColourSetting(
card=WeeklyStatsCard,
property_name='btm_weekly_background_colour',
display_name=_p(
'skinsettings|page:weekly|set:btm_weekly_background_colour|display_name',
'Weekday Bg'
),
description=_p(
'skinsettings|page:weekly|set:btm_weekly_background_colour|desc',
"Weekday axis background."
)
)
btm_bar_horiz_colour = ColourSetting(
card=WeeklyStatsCard,
property_name='btm_bar_horiz_colour',
display_name=_p(
'skinsettings|page:weekly|set:btm_bar_horiz_colour|display_name',
'Bars (Horiz)'
),
description=_p(
'skinsettings|page:weekly|set:btm_bar_horiz_colour|desc',
"Horizontal graph bars."
)
)
btm_bar_vert_colour = ColourSetting(
card=WeeklyStatsCard,
property_name='btm_bar_vert_colour',
display_name=_p(
'skinsettings|page:weekly|set:btm_bar_vert_colour|display_name',
'Bars (Vertical)'
),
description=_p(
'skinsettings|page:weekly|set:btm_bar_vert_colour|desc',
"Vertical graph bars."
)
)
btm_this_colour = ColourSetting(
card=WeeklyStatsCard,
property_name='btm_this_colour',
display_name=_p(
'skinsettings|page:weekly|set:btm_this_colour|display_name',
'This Week'
),
description=_p(
'skinsettings|page:weekly|set:btm_this_colour|desc',
"This week bar fill colour."
)
)
btm_last_colour = ColourSetting(
card=WeeklyStatsCard,
property_name='btm_last_colour',
display_name=_p(
'skinsettings|page:weekly|set:btm_last_colour|display_name',
'Last Week'
),
description=_p(
'skinsettings|page:weekly|set:btm_last_colour|desc',
"Last week bar fill colour."
)
)
btm_day_colour = ColourSetting(
card=WeeklyStatsCard,
property_name='btm_day_colour',
display_name=_p(
'skinsettings|page:weekly|set:btm_day_colour|display_name',
'Hours'
),
description=_p(
'skinsettings|page:weekly|set:btm_day_colour|desc',
"Hour axis labels."
)
)
this_week_colour = ColourSetting(
card=WeeklyStatsCard,
property_name='this_week_colour',
display_name=_p(
'skinsettings|page:weekly|set:this_week_colour|display_name',
'This Week Legend'
),
description=_p(
'skinsettings|page:weekly|set:this_week_colour|desc',
"This week legend text."
)
)
last_week_colour = ColourSetting(
card=WeeklyStatsCard,
property_name='last_week_colour',
display_name=_p(
'skinsettings|page:weekly|set:last_week_colour|display_name',
'Last Week Legend'
),
description=_p(
'skinsettings|page:weekly|set:last_week_colour|desc',
"Last week legend text."
)
)
legend_colour = ColoursSetting(
this_week_colour,
last_week_colour,
display_name=_p(
'skinsettings|page:weekly|set:legend_colour|display_name',
'Legend'
),
description=_p(
'skinsettings|page:weekly|set:legend_colour|desc',
"Legend text colour."
)
)
footer_colour = ColourSetting(
card=WeeklyStatsCard,
property_name='footer_colour',
display_name=_p(
'skinsettings|page:weekly|set:footer_colour|display_name',
'Footer'
),
description=_p(
'skinsettings|page:weekly|set:footer_colour|desc',
"Footer text colour."
)
)
base_skin = SkinSetting(
card=WeeklyStatsCard,
property_name='base_skin_id',
display_name=_p(
'skinsettings|page:weekly|set:base_skin|display_name',
'Skin'
),
description=_p(
'skinsettings|page:weekly|set:base_skin|desc',
"Select a Skin Preset."
)
)
base_skin_group = SettingGroup(
_p('skinsettings|page:weekly|grp:base_skin', "Weekly Stats Skin"),
description=_p(
'skinsettings|page:weekly|grp:base_skin|desc',
"Asset pack and default values for this card."
),
custom_id='weekly-skin',
settings=(base_skin,),
ungrouped=True
)
top_colour_group = SettingGroup(
_p('skinsettings|page:weekly|grp:top_colour', "Top Graph"),
description=_p(
'skinsettings|page:weekly|grp:top_colour|desc',
"Customise the top graph colourscheme."
),
custom_id='weekly-top',
settings=(
top_hours_colour,
top_weekday_colour,
top_date_colour,
top_this_colour,
top_last_colour,
)
)
bottom_colour_group = SettingGroup(
_p('skinsettings|page:weekly|grp:bottom_colour', "Bottom Graph"),
description=_p(
'skinsettings|page:weekly|grp:bottom_colour|desc',
"Customise the bottom graph colourscheme."
),
custom_id='weekly-bottom',
settings=(
btm_weekday_colour,
btm_day_colour,
btm_this_colour,
btm_last_colour,
btm_bar_horiz_colour,
)
)
misc_group = SettingGroup(
_p('skinsettings|page:weekly|grp:misc', "Misc Colours"),
description=_p(
'skinsettings|page:weekly|grp:misc|desc',
"Miscellaneous card colours."
),
custom_id='weekly-misc',
settings=(
title_colour,
legend_colour,
footer_colour,
)
)
base_skin = SkinSetting(
card=WeeklyStatsCard,
property_name='base_skin_id',
display_name=_p(
'skinsettings|page:weekly|set:base_skin|display_name',
'Skin'
),
description=_p(
'skinsettings|page:weekly|set:base_skin|desc',
"Select a Skin Preset."
)
)
base_skin_group = SettingGroup(
_p('skinsettings|page:weekly|grp:base_skin', "Weekly Stats Skin"),
description=_p(
'skinsettings|page:weekly|grp:base_skin|desc',
"Asset pack and default values for the Weekly Statistics."
),
custom_id='weekly-skin',
settings=(base_skin,),
ungrouped=True
)
weekly_page.groups = [
base_skin_group,
top_colour_group,
bottom_colour_group,
misc_group,
]

View File

@@ -0,0 +1,438 @@
from gui.cards import WeeklyGoalCard
from ... import babel
from ..skinsetting import ColourSetting, SkinSetting, ColoursSetting
from ..layout import Page, SettingGroup
_p = babel._p
"""
mini_profile_name_colour
mini_profile_badge_colour
mini_profile_badge_text_colour
mini_profile_discrim_colour
title_colour
footer_colour
progress_bg_colour
progress_colour
[attendance_rate_colour, task_count_colour, studied_hour_colour]
[attendance_colour, task_done_colour, studied_text_colour, task_goal_colour]
task_goal_number_colour
task_header_colour
task_done_number_colour
task_undone_number_colour
task_done_text_colour
task_undone_text_colour
"""
weekly_goal_page = Page(
display_name=_p('skinsettings|page:weekly_goal|display_name', "Weekly Goals"),
editing_description=_p(
'skinsettings|page:weekly_goal|edit_desc',
"Options for the weekly goal card."
),
preview_description=None,
visible_in_preview=True,
render_card=WeeklyGoalCard
)
title_colour = ColourSetting(
card=WeeklyGoalCard,
property_name='title_colour',
display_name=_p(
'skinsettings|page:weekly_goal|set:title_colour|display_name',
"Title"
),
description=_p(
'skinsettings|page:weekly_goal|set:title_colour|desc',
""
)
)
progress_bg_colour = ColourSetting(
card=WeeklyGoalCard,
property_name='progress_bg_colour',
display_name=_p(
'skinsettings|page:weekly_goal|set:progress_bg_colour|display_name',
"Bar Bg"
),
description=_p(
'skinsettings|page:weekly_goal|set:progress_bg_colour|desc',
""
)
)
progress_colour = ColourSetting(
card=WeeklyGoalCard,
property_name='progress_colour',
display_name=_p(
'skinsettings|page:weekly_goal|set:progress_colour|display_name',
"Bar Fg"
),
description=_p(
'skinsettings|page:weekly_goal|set:progress_colour|desc',
""
)
)
attendance_rate_colour = ColourSetting(
card=WeeklyGoalCard,
property_name='attendance_rate_colour',
display_name=_p(
'skinsettings|page:weekly_goal|set:attendance_rate_colour|display_name',
""
),
description=_p(
'skinsettings|page:weekly_goal|set:attendance_rate_colour|desc',
""
)
)
attendance_colour = ColourSetting(
card=WeeklyGoalCard,
property_name='attendance_colour',
display_name=_p(
'skinsettings|page:weekly_goal|set:attendance_colour|display_name',
""
),
description=_p(
'skinsettings|page:weekly_goal|set:attendance_colour|desc',
""
)
)
task_count_colour = ColourSetting(
card=WeeklyGoalCard,
property_name='task_count_colour',
display_name=_p(
'skinsettings|page:weekly_goal|set:task_count_colour|display_name',
""
),
description=_p(
'skinsettings|page:weekly_goal|set:task_count_colour|desc',
""
)
)
task_done_colour = ColourSetting(
card=WeeklyGoalCard,
property_name='task_done_colour',
display_name=_p(
'skinsettings|page:weekly_goal|set:task_done_colour|display_name',
""
),
description=_p(
'skinsettings|page:weekly_goal|set:task_done_colour|desc',
""
)
)
task_goal_colour = ColourSetting(
card=WeeklyGoalCard,
property_name='task_goal_colour',
display_name=_p(
'skinsettings|page:weekly_goal|set:task_goal_colour|display_name',
""
),
description=_p(
'skinsettings|page:weekly_goal|set:task_goal_colour|desc',
""
)
)
task_goal_number_colour = ColourSetting(
card=WeeklyGoalCard,
property_name='task_goal_number_colour',
display_name=_p(
'skinsettings|page:weekly_goal|set:task_goal_number_colour|display_name',
"Task Goal"
),
description=_p(
'skinsettings|page:weekly_goal|set:task_goal_number_colour|desc',
""
)
)
studied_text_colour = ColourSetting(
card=WeeklyGoalCard,
property_name='studied_text_colour',
display_name=_p(
'skinsettings|page:weekly_goal|set:studied_text_colour|display_name',
""
),
description=_p(
'skinsettings|page:weekly_goal|set:studied_text_colour|desc',
""
)
)
studied_hour_colour = ColourSetting(
card=WeeklyGoalCard,
property_name='studied_hour_colour',
display_name=_p(
'skinsettings|page:weekly_goal|set:studied_hour_colour|display_name',
""
),
description=_p(
'skinsettings|page:weekly_goal|set:studied_hour_colour|desc',
""
)
)
text_highlight_colour = ColoursSetting(
attendance_rate_colour,
task_count_colour,
studied_hour_colour,
display_name=_p(
'skinsettings|page:weekly_goal|set:text_highlight_colour|display_name',
"Highlight Text"
),
description=_p(
'skinsettings|page:weekly_goal|set:text_highlight_colour|desc',
"Progress text colour."
)
)
text_colour = ColoursSetting(
attendance_colour,
task_done_colour,
studied_text_colour,
task_goal_colour,
display_name=_p(
'skinsettings|page:weekly_goal|set:text_colour|display_name',
"Text"
),
description=_p(
'skinsettings|page:weekly_goal|set:text_colour|desc',
"Achievement description text colour."
)
)
task_header_colour = ColourSetting(
card=WeeklyGoalCard,
property_name='task_header_colour',
display_name=_p(
'skinsettings|page:weekly_goal|set:task_header_colour|display_name',
"Task Header"
),
description=_p(
'skinsettings|page:weekly_goal|set:task_header_colour|desc',
""
)
)
task_done_number_colour = ColourSetting(
card=WeeklyGoalCard,
property_name='task_done_number_colour',
display_name=_p(
'skinsettings|page:weekly_goal|set:task_done_number_colour|display_name',
"Checked Number"
),
description=_p(
'skinsettings|page:weekly_goal|set:task_done_number_colour|desc',
""
)
)
task_undone_number_colour = ColourSetting(
card=WeeklyGoalCard,
property_name='task_undone_number_colour',
display_name=_p(
'skinsettings|page:weekly_goal|set:task_undone_number_colour|display_name',
"Unchecked Number"
),
description=_p(
'skinsettings|page:weekly_goal|set:task_undone_number_colour|desc',
""
)
)
task_done_text_colour = ColourSetting(
card=WeeklyGoalCard,
property_name='task_done_text_colour',
display_name=_p(
'skinsettings|page:weekly_goal|set:task_done_text_colour|display_name',
"Checked Text"
),
description=_p(
'skinsettings|page:weekly_goal|set:task_done_text_colour|desc',
""
)
)
task_undone_text_colour = ColourSetting(
card=WeeklyGoalCard,
property_name='task_undone_text_colour',
display_name=_p(
'skinsettings|page:weekly_goal|set:task_undone_text_colour|display_name',
"Unchecked Text"
),
description=_p(
'skinsettings|page:weekly_goal|set:task_undone_text_colour|desc',
""
)
)
footer_colour = ColourSetting(
card=WeeklyGoalCard,
property_name='footer_colour',
display_name=_p(
'skinsettings|page:weekly_goal|set:footer_colour|display_name',
"Footer"
),
description=_p(
'skinsettings|page:weekly_goal|set:footer_colour|desc',
""
)
)
mini_profile_badge_colour = ColourSetting(
card=WeeklyGoalCard,
property_name='mini_profile_badge_colour',
display_name=_p(
'skinsettings|page:weekly_goal|set:mini_profile_badge_colour|display_name',
'Badge Background'
),
description=_p(
'skinsettings|page:weekly_goal|set:mini_profile_badge_colour|desc',
"Mini-profile badge background colour."
)
)
mini_profile_badge_text_colour = ColourSetting(
card=WeeklyGoalCard,
property_name='mini_profile_badge_text_colour',
display_name=_p(
'skinsettings|page:weekly_goal|set:mini_profile_badge_text_colour|display_name',
'Badge Text'
),
description=_p(
'skinsettings|page:weekly_goal|set:mini_profile_badge_text_colour|desc',
""
)
)
mini_profile_name_colour = ColourSetting(
card=WeeklyGoalCard,
property_name='mini_profile_name_colour',
display_name=_p(
'skinsettings|page:weekly_goal|set:mini_profile_name_colour|display_name',
'Username'
),
description=_p(
'skinsettings|page:weekly_goal|set:mini_profile_name_colour|desc',
"Mini-profile username colour."
)
)
mini_profile_discrim_colour = ColourSetting(
card=WeeklyGoalCard,
property_name='mini_profile_discrim_colour',
display_name=_p(
'skinsettings|page:weekly_goal|set:mini_profile_discrim_colour|display_name',
'Discriminator'
),
description=_p(
'skinsettings|page:weekly_goal|set:mini_profile_discrim_colour|desc',
"Mini-profile discriminator colour."
)
)
mini_profile_group = SettingGroup(
_p('skinsettings|page:weekly_goal|grp:mini_profile', "Profile"),
description=_p(
'skinsettings|page:weekly_goal|grp:mini_profile|desc',
"Customise the mini-profile shown above the goals."
),
custom_id='weeklygoal-profile',
settings=(
mini_profile_name_colour,
mini_profile_discrim_colour,
mini_profile_badge_colour,
mini_profile_badge_text_colour,
)
)
misc_group = SettingGroup(
_p('skinsettings|page:weekly_goal|grp:misc', "Miscellaneous"),
description=_p(
'skinsettings|page:weekly_goal|grp:misc|desc',
"Other miscellaneous colours."
),
custom_id='weeklygoal-misc',
settings=(
title_colour,
footer_colour,
)
)
task_colour_group = SettingGroup(
_p('skinsettings|page:weekly_goal|grp:task_colour', "Task colours"),
description=_p(
'skinsettings|page:weekly_goal|grp:task_colour|desc',
"Text and number colours for (in)complete goals."
),
custom_id='weeklygoal-tasks',
settings=(
task_undone_number_colour,
task_done_number_colour,
task_undone_text_colour,
task_done_text_colour,
)
)
progress_colour_group = SettingGroup(
_p('skinsettings|page:weekly_goal|grp:progress_colour', "Progress Colours"),
description=_p(
'skinsettings|page:weekly_goal|grp:progress_colour|desc',
"Customise colours for the weekly achievement progress."
),
custom_id='weeklygoal-progress',
settings=(
progress_bg_colour,
progress_colour,
text_colour,
text_highlight_colour,
task_goal_number_colour
)
)
base_skin = SkinSetting(
card=WeeklyGoalCard,
property_name='base_skin_id',
display_name=_p(
'skinsettings|page:weekly_goal|set:base_skin|display_name',
'Skin'
),
description=_p(
'skinsettings|page:weekly_goal|set:base_skin|desc',
"Select a Skin Preset."
)
)
base_skin_group = SettingGroup(
_p('skinsettings|page:weekly_goal|grp:base_skin', "Weekly Goals Skin"),
description=_p(
'skinsettings|page:weekly_goal|grp:base_skin|desc',
"Asset pack and default values for the Weekly Goals."
),
custom_id='weeklygoals-skin',
settings=(base_skin,),
ungrouped=True
)
weekly_goal_page.groups = [
base_skin_group,
mini_profile_group,
misc_group,
progress_colour_group,
task_colour_group
]

View File

@@ -0,0 +1,598 @@
from io import StringIO
import json
from typing import Optional
import asyncio
import datetime as dt
import discord
from discord.ui.button import button, Button, ButtonStyle
from discord.ui.select import select, Select, SelectOption
from gui.base.AppSkin import AppSkin
from meta import LionBot, conf
from meta.errors import ResponseTimedOut, UserInputError
from meta.logger import log_wrap
from utils.ui import FastModal, Confirm, MessageUI, error_handler_for, ModalRetryUI, AButton, AsComponents
from utils.lib import MessageArgs, utc_now
from constants import DATA_VERSION
from .. import babel, logger
from ..skinlib import CustomSkin, FrozenCustomSkin, appskin_as_option
from .pages import pages
from .skinsetting import Setting, SettingInputType, SkinSetting
from .layout import SettingGroup, Page
_p = babel._p
class SettingInput(FastModal):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@error_handler_for(UserInputError)
async def rerequest(self, interaction, error):
await ModalRetryUI(self, error.msg).respond_to(interaction)
class CustomSkinEditor(MessageUI):
def _init_children(self):
# HACK to stop ViewWeights complaining that this UI has too many children
# Children will be correctly initialised after parent init.
return []
def __init__(self, skin: CustomSkin, **kwargs):
super().__init__(timeout=600, **kwargs)
self._children = super()._init_children()
self.skin = skin
self.bot = skin.bot
self.cog = self.bot.get_cog('CustomSkinCog')
self.global_themes = self._get_available()
# UI State
# Whether we are currently in customisation mode
self.customising = False
self.page_index = 0
self.showing_skin_setting: Optional[SkinSetting] = None
# Last item in history is current state
# Last item in future is next state
self.history = [skin.freeze()]
self.future = []
self.dirty = False
@property
def page(self) -> Page:
return pages[self.page_index]
# ----- UI API -----
def push_state(self):
"""
Push a state onto the history stack.
Run this on each change _before_ the refresh.
"""
state = self.skin.freeze()
self.history.append(state)
self.future.clear()
self.dirty = True
def _get_available(self) -> dict[str, AppSkin]:
skins = {
skin.skin_id: skin for skin in AppSkin.get_all()
if skin.public
}
skins['default'] = self._make_default()
return skins
def _make_default(self) -> AppSkin:
"""
Create a placeholder 'default' skin.
"""
t = self.bot.translator.t
skin = AppSkin(None)
skin.skin_id = 'default'
skin.display_name = t(_p(
'ui:skineditor|default_skin:display_name',
"Default"
))
skin.description = t(_p(
'ui:skineditor|default_skin:description',
"My default interface theme"
))
skin.price = 0
return skin
# ----- UI Components -----
# Download button
# NOTE: property_id, card_id, property_name, value
# Metadata with version, time generated, skinid, generating user
# Special field for the global_skin_id
@button(
label="DOWNLOAD_BUTTON_PLACEHOLDER",
style=ButtonStyle.blurple
)
async def download_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer(thinking=True, ephemeral=True)
data = {}
data['metadata'] = {
'requested_by': press.user.id,
'requested_in': press.guild.id if press.guild else None,
'skinid': self.skin.skinid,
'created_at': utc_now().isoformat(),
'data_version': DATA_VERSION,
}
data['custom_skin'] = {
'skinid': self.skin.skinid,
'base_skin': self.skin.base_skin_name,
}
properties = {}
for card, card_props in self.skin.properties.items():
props = {}
for name, value in card_props.items():
propid = (await self.cog.fetch_property_ids((card, name)))[0]
props[name] = {
'property_id': propid,
'value': value
}
properties[card] = props
data['custom_skin']['properties'] = properties
content = json.dumps(data, indent=2)
with StringIO(content) as fp:
fp.seek(0)
file = discord.File(fp, filename=f"skin-{self.skin.skinid}.json")
await press.followup.send("Here is your custom skin data!", file=file, ephemeral=True)
async def download_button_refresh(self):
button = self.download_button
t = self.bot.translator.t
button.label = t(_p(
'ui:skineditor|button:download|label',
"Download"
))
# Save button
@button(
label="SAVE_BUTTON_PLACEHOLDER",
style=ButtonStyle.green
)
async def save_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer(thinking=True, ephemeral=True)
await self.skin.save()
self.history = self.history[-1:]
self.dirty = False
await self.refresh(thinking=press)
async def save_button_refresh(self):
button = self.save_button
t = self.bot.translator.t
button.label = t(_p(
'ui:skineditor|button:save|label',
"Save"
))
button.disabled = not self.dirty
# Back button
@button(
label="BACK_BUTTON_PLACEHOLDER",
style=ButtonStyle.red
)
async def back_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer(thinking=True, ephemeral=True)
self.customising = False
await self.refresh(thinking=press)
async def back_button_refresh(self):
button = self.back_button
t = self.bot.translator.t
button.label = t(_p(
'ui:skineditor|button:back|label',
"Back"
))
# Customise button
@button(
label="CUSTOMISE_BUTTON_PLACEHOLDER",
style=ButtonStyle.green
)
async def customise_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer(thinking=True, ephemeral=True)
self.customising = True
await self.refresh(thinking=press)
async def customise_button_refresh(self):
button = self.customise_button
t = self.bot.translator.t
button.label = t(_p(
'ui:skineditor|button:customise|label',
"Customise"
))
# Reset card button
@button(
label="RESET_CARD_BUTTON_PLACEHOLDER",
style=ButtonStyle.red
)
async def reset_card_button(self, press: discord.Interaction, pressed: Button):
# Note this actually resets the page, not the card
await press.response.defer(thinking=True, ephemeral=True)
for group in self.page.groups:
for setting in group.settings:
setting.set_in(self.skin, None)
self.push_state()
await self.refresh(thinking=press)
async def reset_card_button_refresh(self):
button = self.reset_card_button
t = self.bot.translator.t
button.label = t(_p(
'ui:skineditor|button:reset_card|label',
"Reset Card"
))
# Reset all button
@button(
label="RESET_ALL_BUTTON_PLACEHOLDER",
style=ButtonStyle.red
)
async def reset_all_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer(thinking=True, ephemeral=True)
self.skin.properties.clear()
self.skin.base_skin_name = None
self.push_state()
await self.refresh(thinking=press)
async def reset_all_button_refresh(self):
button = self.reset_all_button
t = self.bot.translator.t
button.label = t(_p(
'ui:skineditor|button:reset_all|label',
"Reset All"
))
# Page selector
@select(
cls=Select,
placeholder="PAGE_MENU_PLACEHOLDER",
min_values=1, max_values=1
)
async def page_menu(self, selection: discord.Interaction, selected: Select):
await selection.response.defer(thinking=True, ephemeral=True)
self.page_index = int(selected.values[0])
self.showing_skin_setting = None
await self.refresh(thinking=selection)
async def page_menu_refresh(self):
menu = self.page_menu
t = self.bot.translator.t
options = []
if not self.customising:
menu.placeholder = t(_p(
'ui:skineditor|menu:page|placeholder:preview',
"Select a card to preview"
))
for i, page in enumerate(pages):
if page.visible_in_preview:
option = SelectOption(
label=t(page.display_name),
value=str(i),
description=t(page.preview_description) if page.preview_description else None
)
option.default = (i == self.page_index)
options.append(option)
else:
menu.placeholder = t(_p(
'ui:skineditor|menu:page|placeholder:edit',
"Select a card to customise"
))
for i, page in enumerate(pages):
option = SelectOption(
label=t(page.display_name),
value=str(i),
description=t(page.editing_description) if page.editing_description else None
)
option.default = (i == self.page_index)
options.append(option)
menu.options = options
# Setting group selector
@select(
cls=Select,
placeholder="GROUP_MENU_PLACEHOLDER",
min_values=1, max_values=1
)
async def group_menu(self, selection: discord.Interaction, selected: Select):
groupid = selected.values[0]
group = next(group for group in self.page.groups if group.custom_id == groupid)
if group.settings[0].input_type is SettingInputType.SkinInput:
self.showing_skin_setting = group.settings[0]
await selection.response.defer(thinking=True, ephemeral=True)
await self.refresh(thinking=selection)
else:
await self._launch_group_editor(selection, group)
async def _launch_group_editor(self, interaction: discord.Interaction, group: SettingGroup):
t = self.bot.translator.t
editable = group.editable_settings
items = [
setting.make_input_field(self.skin)
for setting in editable
]
modal = SettingInput(*items, title=t(group.name))
@modal.submit_callback()
async def group_modal_callback(interaction: discord.Interaction):
values = []
for item, setting in zip(items, editable):
value = await setting.parse_input(self.skin, item.value)
values.append(value)
await interaction.response.defer(thinking=True, ephemeral=True)
for value, setting in zip(values, editable):
setting.set_in(self.skin, value)
self.push_state()
await self.refresh(thinking=interaction)
await interaction.response.send_modal(modal)
async def group_menu_refresh(self):
menu = self.group_menu
t = self.bot.translator.t
menu.placeholder = t(_p(
'ui:skineditor|menu:group|placeholder',
"Select a group or option to customise"
))
options = []
for group in self.page.groups:
option = group.select_option_for(self.skin)
options.append(option)
menu.options = options
# Base skin selector
@select(
cls=Select,
placeholder="SKIN_MENU_PLACEHOLDER",
min_values=1, max_values=1
)
async def skin_menu(self, selection: discord.Interaction, selected: Select):
await selection.response.defer(thinking=True, ephemeral=True)
skin_id = selected.values[0]
if skin_id == 'default':
skin_id = None
if self.customising:
if self.showing_skin_setting:
# Update the current page card with this skin id.
self.showing_skin_setting.set_in(self.skin, skin_id)
else:
# Far more brutal
# Update the global base skin id, and wipe the base skin id for each card
self.skin.base_skin_name = skin_id
for card_id in self.skin.properties:
self.skin.set_prop(card_id, 'base_skin_id', None)
self.push_state()
await self.refresh(thinking=selection)
async def skin_menu_refresh(self):
menu = self.skin_menu
t = self.bot.translator.t
menu.placeholder = t(_p(
'ui:skineditor|menu:skin|placeholder',
"Select a theme"
))
options = []
for skin in self.global_themes.values():
option = appskin_as_option(skin)
options.append(option)
menu.options = options
# Quit button, with confirmation
@button(style=ButtonStyle.grey, emoji=conf.emojis.cancel)
async def quit_button(self, press: discord.Interaction, pressed: Button):
# Confirm quit if there are unsaved changes
if self.dirty:
t = self.bot.translator.t
confirm_msg = t(_p(
'ui:skineditor|button:quit|confirm',
"You have unsaved changes! Are you sure you want to quit?"
))
confirm = Confirm(confirm_msg, self._callerid)
confirm.confirm_button.label = t(_p(
'ui:skineditor|button:quit|confirm|button:yes',
"Yes, Quit Now"
))
confirm.confirm_button.style = ButtonStyle.red
confirm.cancel_button.style = ButtonStyle.green
confirm.cancel_button.label = t(_p(
'ui:skineditor|button:quit|confirm|button:no',
"No, Go Back"
))
try:
result = await confirm.ask(press, ephemeral=True)
except ResponseTimedOut:
result = False
if result:
await self.quit()
else:
await self.quit()
@button(label="UNDO_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple)
async def undo_button(self, press: discord.Interaction, pressed: Button):
"""
Pop the history stack.
"""
if len(self.history) > 1:
state = self.history.pop()
self.future.append(state)
current = self.history[-1]
self.skin.load_frozen(current)
await press.response.defer(thinking=True, ephemeral=True)
await self.refresh(thinking=press)
async def undo_button_refresh(self):
t = self.bot.translator.t
button = self.undo_button
button.label = t(_p(
'ui:skineditor|button:undo|label',
"Undo"
))
button.disabled = (len(self.history) <= 1)
@button(label="REDO_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple)
async def redo_button(self, press: discord.Interaction, pressed: Button):
"""
Pop the future stack.
"""
if len(self.future) > 0:
state = self.future.pop()
self.history.append(state)
current = self.history[-1]
self.skin.load_frozen(current)
await press.response.defer(thinking=True, ephemeral=True)
await self.refresh(thinking=press)
async def redo_button_refresh(self):
t = self.bot.translator.t
button = self.redo_button
button.label = t(_p(
'ui:skineditor|button:redo|label',
"Redo"
))
button.disabled = (len(self.future) == 0)
# ----- UI Flow -----
async def make_message(self) -> MessageArgs:
page = self.page
embed = page.make_embed_for(self.skin)
if page.render_card is not None:
args = self.skin.args_for(page.render_card.card_id)
args.setdefault('base_skin_id', self.cog.current_default)
file = await page.render_card.generate_sample(skin=args)
files = [file]
else:
files = []
return MessageArgs(embed=embed, files=files)
async def refresh_layout(self):
"""
Customising mode:
(card_menu)
(skin_menu?)
(download, save, undo, redo, back,)
Other:
(card_menu)
(theme_menu)
(customise, save, reset_card, reset_all, X)
"""
to_refresh = (
self.page_menu_refresh(),
self.skin_menu_refresh(),
self.undo_button_refresh(),
self.redo_button_refresh(),
self.reset_card_button_refresh(),
self.reset_all_button_refresh(),
self.customise_button_refresh(),
self.back_button_refresh(),
self.save_button_refresh(),
self.group_menu_refresh(),
self.download_button_refresh(),
)
await asyncio.gather(*to_refresh)
if self.customising:
self.set_layout(
(self.page_menu,),
(self.group_menu,),
(self.skin_menu,) if self.showing_skin_setting else (),
(
self.save_button,
self.undo_button,
self.redo_button,
self.download_button,
self.back_button,
),
)
else:
self.set_layout(
(self.page_menu,),
(self.skin_menu,),
(
self.customise_button,
self.save_button,
self.reset_card_button,
self.reset_all_button,
self.quit_button,
),
)
async def reload(self):
...
async def pre_timeout(self):
# Timeout confirmation
if self.dirty:
t = self.bot.translator.t
grace_period = 60
grace_time = utc_now() + dt.timedelta(seconds=grace_period)
embed = discord.Embed(
title=t(_p(
'ui:skineditor|timeout_warning|title',
"Warning!"
)),
description=t(_p(
'ui:skineditor|timeout_warning|desc',
"This interface will time out {timestamp}. Press 'Continue' below to keep editing."
)).format(
timestamp=discord.utils.format_dt(grace_time, style='R')
),
)
components = None
stopped = False
@AButton(label=t(_p('ui:skineditor|timeout_warning|continue', "Continue")), style=ButtonStyle.green)
async def cont_button(interaction: discord.Interaction, pressed):
await interaction.response.defer()
if interaction.message:
await interaction.message.delete()
nonlocal stopped
stopped = True
# TODO: Clean up this mess. It works, but needs to be refactored to a timeout confirmation mixin.
# TODO: Consider moving the message to the interaction response
self._refresh_timeout()
components.stop()
components = AsComponents(cont_button, timeout=grace_period)
channel = self._original.channel if self._original else self._message.channel
message = await channel.send(content=f"<@{self._callerid}>", embed=embed, view=components)
await components.wait()
if not stopped:
try:
await message.delete()
except discord.HTTPException:
pass

View File

@@ -0,0 +1,298 @@
from typing import Literal, Optional
from enum import Enum
from discord.ui import TextInput
from meta import LionBot
from meta.errors import UserInputError
from babel.translator import LazyStr
from gui.base import Card, FieldDesc, AppSkin
from .. import babel
from ..skinlib import CustomSkin
_p = babel._p
class SettingInputType(Enum):
SkinInput = -1
ModalInput = 0
MenuInput = 1
ButtonInput = 2
class Setting:
"""
An abstract base interface for a custom skin 'setting'.
A skin setting is considered to be some readable and usually writeable
information extractable from a `CustomSkin`.
This will usually consist of the value of one or more properties,
which are themselves associated to fields of GUI Cards.
The methods in this ABC describe the interface for such a setting.
Each method accepts a `CustomSkin`,
and an implementation should describe how to
get, set, parse, format, or display the setting
for that given skin.
This is very similar to how Settings are implemented in the bot,
except here all settings have a shared external source of state, the CustomSkin.
Thus, each setting is simply an instance of an appropriate setting class,
rather than a class itself.
"""
# What type of input method this setting requires for input
input_type: SettingInputType = SettingInputType.ModalInput
def __init__(self, *args, display_name, description, **kwargs):
self.display_name: LazyStr = display_name
self.description: LazyStr = description
def default_value_in(self, skin: CustomSkin) -> Optional[str]:
"""
The default value of this setting in this skin.
This takes into account base skin data and localisation.
May be `None` if the setting does not have a default value.
"""
raise NotImplementedError
def value_in(self, skin: CustomSkin) -> Optional[str]:
"""
The current value of this setting from this skin.
May be None if the setting is not set or does not have a value.
Usually should not take into account defaults.
"""
raise NotImplementedError
def set_in(self, skin: CustomSkin, value: Optional[str]):
"""
Set this setting to the given value in this skin.
"""
raise NotImplementedError
def format_value_in(self, skin: CustomSkin, value: Optional[str]) -> str:
"""
Format the given setting value for display (typically in a setting table).
"""
raise NotImplementedError
async def parse_input(self, skin: CustomSkin, userstr: str) -> Optional[str]:
"""
Parse a user provided string into a value for this setting.
Will raise 'UserInputError' with a readable message if parsing fails.
"""
raise NotImplementedError
def make_input_field(self, skin: CustomSkin) -> TextInput:
"""
Create a TextInput field for this setting, using the current value.
"""
raise NotImplementedError
class PropertySetting(Setting):
"""
A skin setting corresponding to a single property of a single card.
Note that this is still abstract,
as it does not implement any formatting or parsing methods.
This will usually (but may not always) correspond to a single Field of the card skin.
"""
def __init__(self, card: type[Card], property_name: str, **kwargs):
super().__init__(**kwargs)
self.card = card
self.property_name = property_name
@property
def card_id(self):
"""
The `card_id` of the Card class this setting belongs to.
"""
return self.card.card_id
@property
def field(self) -> Optional[FieldDesc]:
"""
The CardSkin field overwrriten by this setting, if it exists.
"""
return self.card.skin._fields.get(self.property_name, None)
def default_value_in(self, skin: CustomSkin) -> Optional[str]:
"""
For a PropertySetting, the default value is determined as follows:
base skin value from:
- card base skin
- custom base skin
- global app base skin
fallback (field) value from the CardSkin
"""
base_skin = skin.get_prop(self.card_id, 'base_skin_id')
base_skin = base_skin or skin.base_skin_name
base_skin = base_skin or skin.cog.current_default
app_skin_args = AppSkin.get(base_skin).for_card(self.card_id)
if self.property_name in app_skin_args:
return app_skin_args[self.property_name]
elif self.field:
return self.field.default
else:
return None
def value_in(self, skin: CustomSkin) -> Optional[str]:
return skin.get_prop(self.card_id, self.property_name)
def set_in(self, skin: CustomSkin, value: Optional[str]):
skin.set_prop(self.card_id, self.property_name, value)
class _ColourInterface(Setting):
"""
Skin setting mixin for parsing and formatting colour typed settings.
"""
def format_value_in(self, skin: CustomSkin, value: Optional[str]) -> str:
if value:
formatted = f"`{value}`"
else:
formatted = skin.bot.translator.t(_p(
'skinsettings|colours|format:not_set',
"Not Set"
))
return formatted
async def parse_input(self, skin: CustomSkin, userstr: str) -> Optional[str]:
stripped = userstr.strip('# ').upper()
if not stripped:
value = None
elif len(stripped) not in (6, 8) or any(c not in '0123456789ABCDEF' for c in stripped):
raise UserInputError(
skin.bot.translator.t(_p(
'skinsettings|colours|parse|error:invalid',
"Could not parse `{given}` as a colour!"
" Please use RGB/RGBA format (e.g. `#ABABABF0`)."
)).format(given=userstr)
)
else:
value = f"#{stripped}"
return value
def make_input_field(self, skin: CustomSkin) -> TextInput:
t = skin.bot.translator.t
value = self.value_in(skin)
default_value = self.default_value_in(skin)
label = t(self.display_name)
default = value
if default_value:
placeholder = f"{default_value} ({t(self.description)})"
else:
placeholder = t(self.description)
return TextInput(
label=label,
placeholder=placeholder,
default=default,
min_length=0,
max_length=9,
required=False,
)
class ColourSetting(_ColourInterface, PropertySetting):
"""
A Property skin setting representing a single colour field.
"""
pass
class SkinSetting(PropertySetting):
"""
A Property setting representing the base skin of a card.
"""
input_type = SettingInputType.SkinInput
def format_value_in(self, skin: CustomSkin, value: Optional[str]) -> str:
if value:
app_skin = AppSkin.get(value)
formatted = f"`{app_skin.display_name}`"
else:
formatted = skin.bot.translator.t(_p(
'skinsettings|base_skin|format:not_set',
"Default"
))
return formatted
def default_value_in(self, skin: CustomSkin) -> Optional[str]:
return skin.base_skin_name
class CompoundSetting(Setting):
"""
A Setting combining several PropertySettings across (potentially) multiple cards.
"""
NOTSHARED = ''
def __init__(self, *settings: PropertySetting, **kwargs):
super().__init__(**kwargs)
self.settings = settings
def default_value_in(self, skin: CustomSkin) -> Optional[str]:
"""
The default value of a CompoundSetting is the shared default of the component settings.
If the components do not share a default value, returns None.
"""
value = None
for setting in self.settings:
setting_value = setting.default_value_in(skin)
if setting_value is None:
value = None
break
if value is None:
value = setting_value
elif value != setting_value:
value = None
break
return value
def value_in(self, skin: CustomSkin) -> Optional[str]:
"""
The value of a compound setting is the shared value of the components.
"""
value = self.NOTSHARED
for setting in self.settings:
setting_value = setting.value_in(skin) or setting.default_value_in(skin)
if value is self.NOTSHARED:
value = setting_value
elif value != setting_value:
value = self.NOTSHARED
break
return value
def set_in(self, skin: CustomSkin, value: Optional[str]):
"""
Set all of the components individually.
"""
for setting in self.settings:
setting.set_in(skin, value)
class ColoursSetting(_ColourInterface, CompoundSetting):
"""
Compound setting representing multiple colours.
"""
def format_value_in(self, skin: CustomSkin, value: Optional[str]) -> str:
if value is self.NOTSHARED:
return "Mixed"
elif value is None:
return "Not Set"
else:
return f"`{value}`"

View File

@@ -0,0 +1,55 @@
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'
_event = 'botset_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,181 @@
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['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(custom_skin_id=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
if props:
await self.cog.data.skin_properties.insert_many(
('custom_skin_id', 'property_id', 'value'),
*(
(skinid, self.cog.skin_properties.inverse[propkey], value)
for propkey, value in props.items()
)
)
await self.bot.global_dispatch('skin_updated', skinid)
def get_prop(self, card_id: str, prop_name: str) -> Optional[str]:
return self.properties.get(card_id, {}).get(prop_name, None)
def set_prop(self, card_id: str, prop_name: str, value: Optional[str]):
cardprops = self.properties.get(card_id, None)
if value is None:
if cardprops is not None:
cardprops.pop(prop_name, None)
else:
if cardprops is None:
cardprops = self.properties[card_id] = {}
cardprops[prop_name] = value
def resolve_propid(self, propid: int) -> tuple[str, str]:
return self.cog.skin_properties[propid]
def __getitem__(self, propid: int) -> Optional[str]:
return self.get_prop(*self.resolve_propid(propid))
def __setitem__(self, propid: int, value: Optional[str]):
return self.set_prop(*self.resolve_propid(propid), 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.items())
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

@@ -0,0 +1,552 @@
from typing import Optional
import asyncio
import datetime as dt
import discord
from discord.ui.button import button, Button, ButtonStyle
from discord.ui.select import select, Select, SelectOption
from gui.base.AppSkin import AppSkin
from gui.base.Card import Card
from meta import LionBot, conf
from meta.errors import ResponseTimedOut, UserInputError
from meta.logger import log_wrap
from modules.premium.data import GemTransactionType
from modules.premium.errors import BalanceTooLow
from modules.skins.skinlib import CustomSkin, appskin_as_option
from utils.ui import MessageUI, input, Confirm
from utils.lib import MessageArgs, utc_now
from gui import cards
from . import babel, logger
from .data import CustomSkinData as Data
_p = babel._p
class UserSkinUI(MessageUI):
card_classes = [
cards.ProfileCard,
cards.StatsCard,
cards.WeeklyGoalCard,
cards.WeeklyStatsCard,
cards.MonthlyGoalCard,
cards.MonthlyStatsCard,
]
def __init__(self, bot: LionBot, userid: int, callerid: int, **kwargs):
super().__init__(callerid=callerid, **kwargs)
self.bot = bot
self.cog = bot.get_cog('CustomSkinCog')
self.gems = bot.get_cog('PremiumCog')
self.userid = userid
# UI State
# Map of app_skin_id -> itemid
self.inventory: dict[str, int] = {}
# Active app skin, if any
self.active: Optional[str] = None
# Skins available for purchase
self.available = self._get_available()
# Index of card currently showing
self._card: int = 0
# Name of skin currently displayed, 'default' for default
self._skin: Optional[str] = None
self.balance: int = 0
@property
def current_card(self) -> Card:
return self.card_classes[self._card]
@property
def current_skin(self) -> AppSkin:
if self._skin is None:
raise ValueError("Cannot get current skin before load.")
return self.available[self._skin]
@property
def is_default(self) -> bool:
return (self._skin == 'default')
@property
def is_owned(self) -> bool:
return self.is_default or (self._skin in self.inventory)
@property
def is_equipped(self) -> bool:
return (self.active == self._skin) or (self.is_default and not self.active)
def _get_available(self) -> dict[str, AppSkin]:
skins = {
skin.skin_id: skin for skin in AppSkin.get_all()
if skin.public or (
skin.user_whitelist is not None and
self.userid in skin.user_whitelist
)
}
skins['default'] = self._make_default()
return skins
def _make_default(self) -> AppSkin:
"""
Create a placeholder 'default' skin.
"""
t = self.bot.translator.t
skin = AppSkin(None)
skin.skin_id = 'default'
skin.display_name = t(_p(
'ui:userskins|default_skin:display_name',
"Default"
))
skin.description = t(_p(
'ui:userskins|default_skin:description',
"My default interface theme"
))
skin.price = 0
return skin
# ----- UI API -----
@log_wrap(action='equip skin')
async def _equip_owned_skin(self, itemid: Optional[int]):
"""
Equip the provided item.
if `itemid` is None, 'equips' the default skin.
"""
# Global dispatch
await self.cog.data.UserSkin.table.update_where(
userid=self.userid
).set(active=False)
if itemid is not None:
await self.cog.data.UserSkin.table.update_where(
userid=self.userid, itemid=itemid
).set(active=True)
await self.bot.global_dispatch('userset_skin', self.userid)
@log_wrap(action='purchase skin')
async def _purchase_skin(self, app_skin_name: str):
async with self.bot.db.connection() as conn:
self.bot.db.conn = conn
async with conn.transaction():
skin = self.current_skin
skinid = self.cog.appskin_names.inverse[skin.skin_id]
# Perform transaction
transaction = await self.gems.gem_transaction(
GemTransactionType.PURCHASE,
actorid=self.userid,
from_account=self.userid,
to_account=None,
amount=skin.price,
description=(
f"User purchased custom app skin {skin.skin_id} via UserSkinUI."
),
note=None,
reference=f"iid: {self._original.id if self._original else 'None'}"
)
# Create custom skin
custom_skin = await self.cog.data.CustomisedSkin.create(
base_skin_id=skinid,
)
# Update inventory actives
await self.cog.data.UserSkin.table.update_where(
userid=self.userid
).set(active=False)
# Insert into inventory
await self.cog.data.UserSkin.create(
userid=self.userid,
custom_skin_id=custom_skin.custom_skin_id,
transactionid=transaction.transactionid,
active=True
)
# Global dispatch update
await self.bot.global_dispatch('userset_skin', self.userid)
logger.info(
f"<uid: {self.userid}> purchased skin {skin.skin_id}."
)
# ----- UI Components -----
# Gift Button
@button(
label="GIFT_BUTTON_PLACEHOLDER",
style=ButtonStyle.green,
)
async def gift_button(self, press: discord.Interaction, pressed: Button):
# TODO: Replace with an actual gifting interface
t = self.bot.translator.t
skin = self.current_skin
gift_hint = t(_p(
'ui:userskins|button:gift|response',
"To gift **{skin}** to a friend,"
" send them {gem}**{price}** with {gift_cmd}."
)).format(
skin=skin.display_name,
gem=self.bot.config.emojis.gem,
price=skin.price,
gift_cmd=self.bot.core.mention_cmd('gift'),
)
await press.response.send_message(gift_hint, ephemeral=True)
async def gift_button_refresh(self):
button = self.gift_button
t = self.bot.translator.t
button.label = t(_p(
'ui:userskins|button:gift|label',
"Gift to a friend"
))
price = self.current_skin.price
button.disabled = (
not price or (price > self.balance)
)
# Purchase Button
@button(
label="PURCHASE_BUTTON_PLACEHOLDER",
style=ButtonStyle.green
)
async def purchase_button(self, press: discord.Interaction, pressed: Button):
t = self.bot.translator.t
skin = self.current_skin
# Verify we can purchase this skin
await self.reload()
if self.is_owned:
raise UserInputError(
t(_p(
'ui:userskins|button:purchase|error:already_owned',
"You already own this skin!"
))
)
elif skin.price > self.balance:
raise UserInputError(
t(_p(
'ui:userskins|button:purchase|error:insufficient_gems',
"You don't have enough LionGems to purchase this skin!"
))
)
# Confirm purchase
confirm_msg = t(_p(
'ui:userskins|button:purchase|confirm|desc',
"Are you sure you want to purchase this skin?\n"
"The price of the skin is {gem}**{price}**."
)).format(price=skin.price, gem=self.bot.config.emojis.gem)
confirm = Confirm(confirm_msg, press.user.id)
confirm.embed.set_footer(
text=t(_p(
'ui:userskins|button:purchase|confirm|footer',
"Your current balance is {balance} LionGems"
)).format(balance=self.balance)
)
try:
result = await confirm.ask(press, ephemeral=True)
except ResponseTimedOut:
result = False
if result:
try:
await self._purchase_skin(skin.skin_id)
except BalanceTooLow:
raise UserInputError(
t(_p(
'ui:userskins|button:purchase|error:insufficient_gems_post_confirm',
"Insufficient LionGems to purchase this skin!"
))
)
# Ack purchase and refresh
embed = discord.Embed(
colour=discord.Colour.brand_green(),
title=t(_p(
'ui:userskins|button:purchase|embed:success|title',
"Skin Purchase"
)),
description=t(_p(
'ui:userskins|button:purchase|embed:success|desc',
"You have purchased and equipped the skin **{name}**!\n"
"Thank you for your support, and enjoy your new purchase!"
)).format(name=skin.display_name)
)
await press.followup.send(embed=embed, ephemeral=True)
await self.refresh()
async def purchase_button_refresh(self):
button = self.purchase_button
t = self.bot.translator.t
button.label = t(_p(
'ui:userskins|button:purchase|label',
"Purchase Skin"
))
button.disabled = (
self.is_owned
or self.current_skin.price > self.balance
)
# Equip Button
@button(
label="EQUIP_BUTTON_PLACEHOLDER",
style=ButtonStyle.green
)
async def equip_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer(thinking=True, ephemeral=True)
t = self.bot.translator.t
to_equip = None if self.is_default else self.inventory[self._skin]
await self._equip_owned_skin(to_equip)
embed = discord.Embed(
colour=discord.Colour.brand_green(),
title=t(_p(
'ui:userskins|button:equip|embed:success|title',
"Skin Equipped"
)),
description=t(_p(
'ui:userskins|button:equip|embed:success|desc',
"You have equpped your **{name}** skin!"
)).format(name=self.current_skin.display_name)
)
await press.edit_original_response(embed=embed)
await self.refresh()
async def equip_button_refresh(self):
button = self.equip_button
t = self.bot.translator.t
button.label = t(_p(
'ui:userskins|button:equip|label',
"Equip Skin"
))
button.disabled = (
self.is_equipped or not self.is_owned
)
# Price button
@button(
label="PRICE_BUTTON_PLACEHOLDER",
style=ButtonStyle.green,
emoji=conf.emojis.gem,
)
async def price_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer(thinking=False)
async def price_button_refresh(self):
button = self.price_button
t = self.bot.translator.t
price = self.current_skin.price
button.label = t(_p(
'ui:userskins|button:price|label',
"{price} Gems"
)).format(price=price)
if price < self.balance:
button.style = ButtonStyle.green
else:
button.style = ButtonStyle.danger
# Card Menu
@select(
cls=Select,
placeholder="CARD_MENU_PLACEHOLDER",
min_values=1, max_values=1
)
async def card_menu(self, selection: discord.Interaction, selected: Select):
await selection.response.defer(thinking=True, ephemeral=True)
self._card = int(selected.values[0])
await self.refresh(thinking=selection)
async def card_menu_refresh(self):
menu = self.card_menu
t = self.bot.translator.t
menu.placeholder = t(_p(
'ui:userskins|menu:card|placeholder',
"Select a card to preview"
))
options = []
for i, card in enumerate(self.card_classes):
option = SelectOption(
label=t(card.display_name),
value=str(i),
default=(i == self._card)
)
options.append(option)
menu.options = options
@button(emoji=conf.emojis.cancel, style=ButtonStyle.red)
async def quit_button(self, press: discord.Interaction, pressed: Button):
"""
Quit the UI.
"""
await press.response.defer()
await self.quit()
# Skin Menu
@select(
cls=Select,
placeholder="SKIN_MENU_PLACEHOLDER",
min_values=1, max_values=1
)
async def skin_menu(self, selection: discord.Interaction, selected: Select):
await selection.response.defer(thinking=True, ephemeral=True)
self._skin = selected.values[0]
await self.refresh(thinking=selection)
async def skin_menu_refresh(self):
menu = self.skin_menu
t = self.bot.translator.t
menu.placeholder = t(_p(
'ui:userskins|menu:skin|placeholder',
"Select a skin."
))
options = []
for skin in self.available.values():
option = appskin_as_option(skin)
if skin.skin_id == self._skin:
option.default = True
options.append(option)
menu.options = options
# ----- UI Flow -----
async def _render_card(self) -> discord.File:
if not self._skin:
raise ValueError("Rendering UserSkinUI before load.")
use_skin = None
if self._skin == 'default':
use_skin = await self.cog.get_default_skin()
else:
use_skin = self._skin
skin = {'base_skin_id': use_skin} if use_skin else {}
return await self.current_card.generate_sample(skin=skin)
async def make_message(self) -> MessageArgs:
if not self._skin:
raise ValueError("Rendering UserSkinUI before load.")
t = self.bot.translator.t
skin = self.current_skin
# Compute tagline
if not self.is_owned:
if skin.price <= self.balance:
tagline = t(_p(
'ui:userskins|tagline:purchase',
"Purchase this skin for {gem}{price}"
))
else:
tagline = t(_p(
'ui:userskins|tagline:insufficient',
"You don't have enough LionGems to buy this skin!"
))
elif not self.is_equipped:
tagline = t(_p(
'ui:userskins|tagline:equip',
"You already own this skin! Clock Equip to use it!"
))
else:
tagline = t(_p(
'ui:userskins|tagline:current',
"This is your current skin!"
))
tagline = tagline.format(
gem=self.bot.config.emojis.gem,
price=skin.price,
)
embed = discord.Embed(
colour=discord.Colour.orange(),
title=skin.display_name,
description=f"{skin.description}\n\n***{tagline}***"
)
embed.set_footer(
icon_url="https://cdn.discordapp.com/attachments/925799205954543636/938703943683416074/4CF1C849-D532-4DEC-B4C9-0AB11F443BAB.png",
text=t(_p(
'ui:userskins|footer',
"Current Balance: {balance} LionGems"
)).format(balance=self.balance)
)
embed.set_image(url='attachment://sample.png')
file = await self._render_card()
return MessageArgs(embed=embed, files=[file])
async def refresh_layout(self):
"""
(gift_button, price_button, action_button)
(skin_menu,),
(card_menu,),
"""
to_refresh = (
self.gift_button_refresh(),
self.price_button_refresh(),
self.purchase_button_refresh(),
self.equip_button_refresh(),
self.card_menu_refresh(),
self.skin_menu_refresh(),
)
await asyncio.gather(*to_refresh)
# Determine action button
skin = self.current_skin
if not self.is_owned:
if skin.price <= self.balance:
action = self.purchase_button
else:
action = self.gems.buy_gems_button()
else:
action = self.equip_button
self.set_layout(
(self.gift_button, self.price_button, action, self.quit_button,),
(self.skin_menu,),
(self.card_menu,),
)
async def reload(self):
"""
Load the user's skin inventory.
"""
records = await self.cog.data.UserSkin.table.select_where(
userid=self.userid
).join(
'customised_skins', using=('custom_skin_id',)
).select(
'itemid', 'custom_skin_id', 'base_skin_id', 'active'
).with_no_adapter()
active = None
inventory = {}
for record in records:
base_skin_name = self.cog.appskin_names[record['base_skin_id']]
inventory[base_skin_name] = record['itemid']
if record['active']:
active = base_skin_name
self.inventory = inventory
self.active = active
if self._skin is None:
self._skin = active or 'default'
self.balance = await self.gems.get_gem_balance(self.userid)

View File

@@ -36,8 +36,12 @@ class SponsorCog(LionCog):
"""
if not interaction.is_expired():
# TODO: caching
if interaction.guild:
whitelist = (await self.settings.Whitelist.get(self.bot.appname)).value
if interaction.guild and interaction.guild.id in whitelist:
if interaction.guild.id in whitelist:
return
premiumcog = self.bot.get_cog('PremiumCog')
if premiumcog and await premiumcog.is_premium_guild(interaction.guild.id):
return
setting = await self.settings.SponsorPrompt.get(self.bot.appname)
value = setting.value

View File

@@ -122,6 +122,11 @@ async def get_goals_card(
badges = await data.ProfileTag.fetch_tags(guildid, userid)
card_cls = WeeklyGoalCard if weekly else MonthlyGoalCard
skin = await bot.get_cog('CustomSkinCog').get_skinargs_for(
guildid, userid, card_cls.card_id
)
card = card_cls(
name=username[0],
discrim=username[1],
@@ -134,6 +139,6 @@ async def get_goals_card(
attendance=attendance,
goals=tasks,
date=today,
skin={'mode': mode}
skin=skin | {'mode': mode}
)
return card

View File

@@ -60,8 +60,12 @@ async def get_leaderboard_card(
highlight = position
# Request Card
skin = await bot.get_cog('CustomSkinCog').get_skinargs_for(
guildid, None, LeaderboardCard.card_id
)
card = LeaderboardCard(
skin={'mode': mode},
skin=skin | {'mode': mode},
server_name=guild.name,
entries=entries,
highlight=highlight

View File

@@ -121,6 +121,9 @@ async def get_monthly_card(bot: LionBot, userid: int, guildid: int, offset: int,
username = (lion.data.display_name, '#????')
# Request card
skin = await bot.get_cog('CustomSkinCog').get_skinargs_for(
guildid, userid, MonthlyStatsCard.card_id
)
card = MonthlyStatsCard(
user=username,
timezone=str(lion.timezone),
@@ -129,6 +132,6 @@ async def get_monthly_card(bot: LionBot, userid: int, guildid: int, offset: int,
monthly=monthly,
current_streak=current_streak,
longest_streak=longest_streak,
skin={'mode': mode}
skin=skin | {'mode': mode}
)
return card

View File

@@ -80,6 +80,10 @@ async def get_profile_card(bot: LionBot, userid: int, guildid: int):
achievements = await get_achievements_for(bot, guildid, userid)
achieved = tuple(ach.emoji_index for ach in achievements if ach.achieved)
skin = await bot.get_cog('CustomSkinCog').get_skinargs_for(
guildid, userid, ProfileCard.card_id
)
card = ProfileCard(
user=username,
avatar=(userid, avatar),
@@ -88,6 +92,7 @@ async def get_profile_card(bot: LionBot, userid: int, guildid: int):
achievements=achieved,
current_rank=current_rank,
rank_progress=rank_progress,
next_rank=next_rank
next_rank=next_rank,
skin=skin,
)
return card

View File

@@ -117,12 +117,16 @@ async def get_stats_card(bot: LionBot, userid: int, guildid: int, mode: CardMode
if streak_start is not None:
streaks.append((streak_start, today.day))
skin = await bot.get_cog('CustomSkinCog').get_skinargs_for(
guildid, userid, StatsCard.card_id
)
card = StatsCard(
(position, 0),
period_strings,
month_string,
100,
streaks,
skin={'mode': mode}
skin=skin | {'mode': mode}
)
return card

View File

@@ -58,6 +58,10 @@ async def get_weekly_card(bot: LionBot, userid: int, guildid: int, offset: int,
else:
username = (lion.data.display_name, '#????')
skin = await bot.get_cog('CustomSkinCog').get_skinargs_for(
guildid, userid, WeeklyStatsCard.card_id
)
card = WeeklyStatsCard(
user=username,
timezone=str(lion.timezone),
@@ -68,6 +72,6 @@ async def get_weekly_card(bot: LionBot, userid: int, guildid: int, offset: int,
(int(session['start_time'].timestamp()), int(session['start_time'].timestamp() + int(session['duration'])))
for session in sessions
],
skin={'mode': mode}
skin=skin | {'mode': mode}
)
return card

View File

@@ -537,9 +537,10 @@ class LeaderboardUI(StatsUI):
period_row,
page_row
]
voting = self.bot.get_cog('TopggCog')
if voting and not await voting.check_voted_recently(self.userid):
premiumcog = self.bot.get_cog('PremiumCog')
if not (premiumcog and await premiumcog.is_premium_guild(self.guild.id)):
self._layout.append((voting.vote_button(),))
async def reload(self):

View File

@@ -299,6 +299,8 @@ class ProfileUI(StatsUI):
voting = self.bot.get_cog('TopggCog')
if voting and not await voting.check_voted_recently(self.userid):
premiumcog = self.bot.get_cog('PremiumCog')
if not (premiumcog and await premiumcog.is_premium_guild(self.guild.id)):
self._layout.append((voting.vote_button(),))
async def _render_stats(self):

View File

@@ -753,6 +753,8 @@ class WeeklyMonthlyUI(StatsUI):
voting = self.bot.get_cog('TopggCog')
if voting and not await voting.check_voted_recently(self.userid):
premiumcog = self.bot.get_cog('PremiumCog')
if not (premiumcog and await premiumcog.is_premium_guild(self.guild.id)):
self._layout.append((voting.vote_button(),))
if self._showing_selector:

View File

@@ -431,7 +431,7 @@ class Exec(LionCog):
results = [
appcmd.Choice(name=f"No extensions found matching {partial}", value="None")
]
return results
return results[:25]
@commands.hybrid_command(
name=_('shutdown'),