diff --git a/LICENSE.md b/LICENSE.md index 3a6fd6bb..b797f650 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -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. diff --git a/README.md b/README.md index 1691eed3..9a35e94d 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/config/example-bot.conf b/config/example-bot.conf index b27c143e..feb33705 100644 --- a/config/example-bot.conf +++ b/config/example-bot.conf @@ -12,10 +12,12 @@ ALSO_READ = config/emojis.conf, config/secrets.conf, config/gui.conf asset_path = assets support_guild = +invite_bot = + [ENDPOINTS] guild_log = -gem_transaction = +gem_log = [LOGGING] log_file = bot.log @@ -50,3 +52,8 @@ domains = base, wards, schedule, shop, moderation, economy, user_config, config, [TEXT_TRACKER] batchsize = 1 batchtime = 600 + +[TOPGG] +enabled = false +route = /dbl +port = 5000 diff --git a/config/example-secrets.conf b/config/example-secrets.conf index 01e529d2..e8ffc078 100644 --- a/config/example-secrets.conf +++ b/config/example-secrets.conf @@ -4,3 +4,6 @@ token = [DATA] args = dbname=lion_data appid = StudyLion + +[TOPGG] +auth = diff --git a/data/migration/v13-v14/migration.sql b/data/migration/v13-v14/migration.sql new file mode 100644 index 00000000..3882faac --- /dev/null +++ b/data/migration/v13-v14/migration.sql @@ -0,0 +1,7 @@ +BEGIN; + +ALTER TABLE bot_config ADD COLUMN sponsor_prompt TEXT; +ALTER TABLE bot_config ADD COLUMN sponsor_message TEXT; + +INSERT INTO VersionHistory (version, author) VALUES (14, 'v13-v14 migration'); +COMMIT; diff --git a/data/schema.sql b/data/schema.sql index 26a0c607..dbd057fc 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -4,7 +4,7 @@ CREATE TABLE VersionHistory( time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, author TEXT ); -INSERT INTO VersionHistory (version, author) VALUES (13, 'Initial Creation'); +INSERT INTO VersionHistory (version, author) VALUES (14, 'Initial Creation'); CREATE OR REPLACE FUNCTION update_timestamp_column() @@ -17,17 +17,6 @@ $$ language 'plpgsql'; -- }}} -- App metadata {{{ -CREATE TABLE AppData( - appid TEXT PRIMARY KEY, - last_study_badge_scan TIMESTAMP -); - -CREATE TABLE AppConfig( - appid TEXT, - key TEXT, - value TEXT, - PRIMARY KEY(appid, key) -); CREATE TABLE global_user_blacklist( userid BIGINT PRIMARY KEY, @@ -50,6 +39,8 @@ CREATE TABLE app_config( CREATE TABLE bot_config( appname TEXT PRIMARY KEY REFERENCES app_config(appname) ON DELETE CASCADE, + sponsor_prompt TEXT, + sponsor_message TEXT, default_skin TEXT ); @@ -1227,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, diff --git a/requirements.txt b/requirements.txt index 011dd9dc..af59fce9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,5 @@ topggpy psutil pillow python-dateutil +bidict +frozendict diff --git a/src/babel/cog.py b/src/babel/cog.py index 440db01c..bc649033 100644 --- a/src/babel/cog.py +++ b/src/babel/cog.py @@ -41,7 +41,7 @@ class BabelCog(LionCog): self.bot.core.user_config.register_model_setting(LocaleSettings.UserLocale) configcog = self.bot.get_cog('ConfigCog') - self.crossload_group(self.configure_group, configcog.configure_group) + self.crossload_group(self.configure_group, configcog.config_group) userconfigcog = self.bot.get_cog('UserConfigCog') self.crossload_group(self.userconfig_group, userconfigcog.userconfig_group) @@ -114,8 +114,6 @@ class BabelCog(LionCog): language=LocaleSettings.GuildLocale._display_name, force_language=LocaleSettings.ForceLocale._display_name ) - @appcmds.guild_only() # Can be removed when attached as a subcommand - @appcmds.default_permissions(manage_guild=True) @low_management_ward async def cmd_configure_language(self, ctx: LionContext, language: Optional[str] = None, diff --git a/src/babel/settings.py b/src/babel/settings.py index 24c45914..5d77f1bb 100644 --- a/src/babel/settings.py +++ b/src/babel/settings.py @@ -7,6 +7,7 @@ from settings.groups import SettingGroup from meta.errors import UserInputError from meta.context import ctx_bot from core.data import CoreData +from wards import low_management_iward from .translator import ctx_translator from . import babel @@ -104,9 +105,10 @@ class LocaleSettings(SettingGroup): """ Guild configuration for whether to force usage of the guild locale. - Exposed via `/configure language` command and standard configuration interface. + Exposed via `/config language` command and standard configuration interface. """ setting_id = 'force_locale' + _write_ward = low_management_iward _display_name = _p('guildset:force_locale', 'force_language') _desc = _p('guildset:force_locale|desc', @@ -144,15 +146,16 @@ class LocaleSettings(SettingGroup): def set_str(self): bot = ctx_bot.get() if bot: - return bot.core.mention_cmd('configure language') + return bot.core.mention_cmd('config language') class GuildLocale(ModelData, LocaleSetting): """ Guild-configured locale. - Exposed via `/configure language` command, and standard configuration interface. + Exposed via `/config language` command, and standard configuration interface. """ setting_id = 'guild_locale' + _write_ward = low_management_iward _display_name = _p('guildset:locale', 'language') _desc = _p('guildset:locale|desc', "Your preferred language for interacting with me.") @@ -180,4 +183,4 @@ class LocaleSettings(SettingGroup): def set_str(self): bot = ctx_bot.get() if bot: - return bot.core.mention_cmd('configure language') + return bot.core.mention_cmd('config language') diff --git a/src/babel/settingui.py b/src/babel/settingui.py index 0449d1f6..2be92865 100644 --- a/src/babel/settingui.py +++ b/src/babel/settingui.py @@ -29,6 +29,7 @@ class LocaleSettingUI(ConfigUI): async def force_button(self, press: discord.Interaction, pressed: Button): await press.response.defer() setting = next(inst for inst in self.instances if inst.setting_id == LocaleSettings.ForceLocale.setting_id) + await setting.interaction_check(self.guildid, press) setting.value = not setting.value await setting.write() @@ -80,7 +81,7 @@ class LocaleSettingUI(ConfigUI): class LocaleDashboard(DashboardSection): section_name = _p( 'dash:locale|title', - "Server Language Configuration ({commands[configure language]})" + "Server Language Configuration ({commands[config language]})" ) _option_name = _p( "dash:locale|dropdown|placeholder", diff --git a/src/constants.py b/src/constants.py index 87b445d1..cb4c4d77 100644 --- a/src/constants.py +++ b/src/constants.py @@ -1,5 +1,5 @@ CONFIG_FILE = "config/bot.conf" -DATA_VERSION = 13 +DATA_VERSION = 14 MAX_COINS = 2147483647 - 1 diff --git a/src/core/cog.py b/src/core/cog.py index d78f6dde..dde79881 100644 --- a/src/core/cog.py +++ b/src/core/cog.py @@ -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}") diff --git a/src/core/config.py b/src/core/config.py index 0468c715..7ea07736 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -25,12 +25,35 @@ class ConfigCog(LionCog): ... @cmds.hybrid_group( - name=_p('group:configure', "configure"), - description=_p('group:configure|desc', "View and adjust my configuration options."), + name=_p('group:config', "config"), + description=_p('group:config|desc', "View and adjust moderation-level configuration."), ) @appcmds.guild_only @appcmds.default_permissions(manage_guild=True) - async def configure_group(self, ctx: LionContext): + async def config_group(self, ctx: LionContext): + """ + Bare command group, has no function. + """ + return + + @cmds.hybrid_group( + name=_p('group:admin', "admin"), + description=_p('group:admin|desc', "Administrative commands."), + ) + @appcmds.guild_only + @appcmds.default_permissions(administrator=True) + async def admin_group(self, ctx: LionContext): + """ + Bare command group, has no function. + """ + return + + @admin_group.group( + name=_p('group:admin_config', "config"), + description=_p('group:admin_config|desc', "View and adjust admin-level configuration."), + ) + @appcmds.guild_only + async def admin_config_group(self, ctx: LionContext): """ Bare command group, has no function. """ diff --git a/src/core/data.py b/src/core/data.py index 177fccd6..47fed694 100644 --- a/src/core/data.py +++ b/src/core/data.py @@ -47,6 +47,8 @@ class CoreData(Registry, name="core"): ------ CREATE TABLE bot_config( appname TEXT PRIMARY KEY REFERENCES app_config(appname) ON DELETE CASCADE, + sponsor_prompt TEXT, + sponsor_message TEXT, default_skin TEXT ); """ @@ -54,6 +56,8 @@ class CoreData(Registry, name="core"): appname = String(primary=True) default_skin = String() + sponsor_prompt = String() + sponsor_message = String() class Shard(RowModel): """ diff --git a/src/gui b/src/gui index f2760218..c1bcb05c 160000 --- a/src/gui +++ b/src/gui @@ -1 +1 @@ -Subproject commit f2760218ef065f1cde53b801b184cfe02f24dff0 +Subproject commit c1bcb05c25cd2ecec7dd726d55d30606b6b5c99b diff --git a/src/meta/LionBot.py b/src/meta/LionBot.py index 7ee77493..4b32df48 100644 --- a/src/meta/LionBot.py +++ b/src/meta/LionBot.py @@ -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__}") diff --git a/src/modules/__init__.py b/src/modules/__init__.py index ec4cbdb6..416fbac9 100644 --- a/src/modules/__init__.py +++ b/src/modules/__init__.py @@ -4,6 +4,7 @@ active = [ '.sysadmin', '.config', '.user_config', + '.skins', '.schedule', '.economy', '.ranks', @@ -20,6 +21,9 @@ active = [ '.meta', '.blanket', '.voicefix', + '.sponsors', + '.topgg', + '.premium', '.test', ] diff --git a/src/modules/config/cog.py b/src/modules/config/cog.py index 307b1df0..c146f768 100644 --- a/src/modules/config/cog.py +++ b/src/modules/config/cog.py @@ -29,14 +29,14 @@ class GuildConfigCog(LionCog): configcog = self.bot.get_cog('ConfigCog') if configcog is None: raise ValueError("Cannot load GuildConfigCog without ConfigCog") - self.crossload_group(self.configure_group, configcog.configure_group) + self.crossload_group(self.configure_group, configcog.config_group) @cmds.hybrid_command( name="dashboard", description="At-a-glance view of the server's configuration." ) @appcmds.guild_only - @appcmds.default_permissions(manage_guild=True) + @low_management_ward async def dashboard_cmd(self, ctx: LionContext): if not ctx.guild or not ctx.interaction: return @@ -64,8 +64,6 @@ class GuildConfigCog(LionCog): timezone=GeneralSettings.Timezone._desc, event_log=GeneralSettings.EventLog._desc, ) - @appcmds.guild_only() - @appcmds.default_permissions(manage_guild=True) @low_management_ward async def cmd_configure_general(self, ctx: LionContext, timezone: Optional[str] = None, diff --git a/src/modules/config/settings.py b/src/modules/config/settings.py index 87c5f0d4..6403980a 100644 --- a/src/modules/config/settings.py +++ b/src/modules/config/settings.py @@ -9,6 +9,7 @@ from meta.context import ctx_bot from meta.errors import UserInputError from core.data import CoreData from babel.translator import ctx_translator +from wards import low_management_iward from . import babel @@ -20,13 +21,14 @@ class GeneralSettings(SettingGroup): """ Guild timezone configuration. - Exposed via `/configure general timezone:`, and the standard interface. + Exposed via `/config general timezone:`, and the standard interface. The `timezone` setting acts as the default timezone for all members, and the timezone used to display guild-wide statistics. """ setting_id = 'timezone' _event = 'guildset_timezone' - _set_cmd = 'configure general' + _set_cmd = 'config general' + _write_ward = low_management_iward _display_name = _p('guildset:timezone', "timezone") _desc = _p( @@ -58,7 +60,8 @@ class GeneralSettings(SettingGroup): """ setting_id = 'eventlog' _event = 'guildset_eventlog' - _set_cmd = 'configure general' + _set_cmd = 'config general' + _write_ward = low_management_iward _display_name = _p('guildset:eventlog', "event_log") _desc = _p( diff --git a/src/modules/config/settingui.py b/src/modules/config/settingui.py index 3359fa9d..48e441a7 100644 --- a/src/modules/config/settingui.py +++ b/src/modules/config/settingui.py @@ -41,6 +41,7 @@ class GeneralSettingUI(ConfigUI): await selection.response.defer(thinking=True, ephemeral=True) setting = self.get_instance(GeneralSettings.EventLog) + await setting.interaction_check(setting.parent_id, selection) value = selected.values[0].resolve() if selected.values else None setting = await setting.from_value(self.guildid, value) @@ -95,7 +96,7 @@ class GeneralSettingUI(ConfigUI): class GeneralDashboard(DashboardSection): section_name = _p( "dash:general|title", - "General Configuration ({commands[configure general]})" + "General Configuration ({commands[config general]})" ) _option_name = _p( "dash:general|option|name", diff --git a/src/modules/economy/cog.py b/src/modules/economy/cog.py index db383d4f..16cde9d2 100644 --- a/src/modules/economy/cog.py +++ b/src/modules/economy/cog.py @@ -64,7 +64,7 @@ class Economy(LionCog): "Attempting to load the EconomyCog before ConfigCog! Failed to crossload configuration group." ) else: - self.crossload_group(self.configure_group, configcog.configure_group) + self.crossload_group(self.configure_group, configcog.config_group) # ----- Economy Bonus registration ----- def register_economy_bonus(self, bonus_coro, name=None): @@ -903,7 +903,6 @@ class Economy(LionCog): appcmds.Choice(name=EconomySettings.AllowTransfers._outputs[False], value=0), ] ) - @appcmds.default_permissions(manage_guild=True) @moderator_ward async def configure_economy(self, ctx: LionContext, allow_transfers: Optional[appcmds.Choice[int]] = None, diff --git a/src/modules/economy/settings.py b/src/modules/economy/settings.py index 076710d4..1270323b 100644 --- a/src/modules/economy/settings.py +++ b/src/modules/economy/settings.py @@ -17,6 +17,7 @@ from meta.logger import log_wrap from core.data import CoreData from core.setting_types import CoinSetting from babel.translator import ctx_translator +from wards import low_management_iward from . import babel, logger from .data import EconomyData @@ -32,6 +33,7 @@ class EconomySettings(SettingGroup): """ class CoinsPerXP(ModelData, CoinSetting): setting_id = 'coins_per_xp' + _write_ward = low_management_iward _display_name = _p('guildset:coins_per_xp', "coins_per_100xp") _desc = _p( @@ -63,10 +65,11 @@ class EconomySettings(SettingGroup): @property def set_str(self): bot = ctx_bot.get() - return bot.core.mention_cmd('configure economy') if bot else None + return bot.core.mention_cmd('config economy') if bot else None class AllowTransfers(ModelData, BoolSetting): setting_id = 'allow_transfers' + _write_ward = low_management_iward _display_name = _p('guildset:allow_transfers', "allow_transfers") _desc = _p( @@ -91,7 +94,7 @@ class EconomySettings(SettingGroup): @property def set_str(self): bot = ctx_bot.get() - return bot.core.mention_cmd('configure economy') if bot else None + return bot.core.mention_cmd('config economy') if bot else None @property def update_message(self): @@ -115,6 +118,7 @@ class EconomySettings(SettingGroup): class StartingFunds(ModelData, CoinSetting): setting_id = 'starting_funds' + _write_ward = low_management_iward _display_name = _p('guildset:starting_funds', "starting_funds") _desc = _p( diff --git a/src/modules/economy/settingui.py b/src/modules/economy/settingui.py index f357d6e5..64b2091b 100644 --- a/src/modules/economy/settingui.py +++ b/src/modules/economy/settingui.py @@ -64,7 +64,7 @@ class EconomyConfigUI(ConfigUI): class EconomyDashboard(DashboardSection): section_name = _p( 'dash:economy|title', - "Economy Configuration ({commands[configure economy]})" + "Economy Configuration ({commands[config economy]})" ) _option_name = _p( "dash:economy|dropdown|placeholder", diff --git a/src/modules/member_admin/cog.py b/src/modules/member_admin/cog.py index 250db0b0..1aa20c49 100644 --- a/src/modules/member_admin/cog.py +++ b/src/modules/member_admin/cog.py @@ -1,15 +1,23 @@ +from io import StringIO from typing import Optional import asyncio import discord from discord.ext import commands as cmds +from discord.enums import AppCommandOptionType from discord import app_commands as appcmds +from psycopg import sql +from data.queries import NULLS, ORDER from meta import LionCog, LionBot, LionContext from meta.logger import log_wrap from meta.sharding import THIS_SHARD +from meta.errors import UserInputError, SafeCancellation from babel.translator import ctx_locale -from utils.lib import utc_now +from utils.lib import utc_now, parse_time_static, write_records +from utils.ui import ChoicedEnum, Transformed +from utils.ratelimits import Bucket, BucketFull, BucketOverFull +from data import RawExpr, NULL from wards import low_management_ward, equippable_role, high_management_ward @@ -21,6 +29,24 @@ from .settingui import MemberAdminUI _p = babel._p +class DownloadableData(ChoicedEnum): + VOICE_LEADERBOARD = _p('cmd:admin_data|param:data_type|choice:voice_leaderboard', "Voice Leaderboard") + MSG_LEADERBOARD = _p('cmd:admin_data|param:data_type|choice:msg_leaderboard', "Message Leaderboard") + XP_LEADERBOARD = _p('cmd:admin_data|param:data_type|choice:xp_leaderboard', "XP Leaderboard") + ROLEMENU_EQUIP = _p('cmd:admin_data|param:data_type|choice:rolemenu_equip', "Rolemenu Roles Equipped") + TRANSACTIONS = _p('cmd:admin_data|param:data_type|choice:transactions', "Economy Transactions (Incomplete)") + BALANCES = _p('cmd:admin_data|param:data_type|choice:balances', "Economy Balances") + VOICE_SESSIONS = _p('cmd:admin_data|param:data_type|choice:voice_sessions', "Voice Sessions") + + @property + def choice_name(self): + return self.value + + + @property + def choice_value(self): + return self.name + class MemberAdminCog(LionCog): def __init__(self, bot: LionBot): self.bot = bot @@ -31,6 +57,9 @@ class MemberAdminCog(LionCog): # Set of (guildid, userid) that are currently being added self._adding_roles = set() + # Map of guildid -> Bucket + self._data_request_buckets: dict[int, Bucket] = {} + # ----- Initialisation ----- async def cog_load(self): await self.data.init() @@ -46,7 +75,8 @@ class MemberAdminCog(LionCog): "Configuration command cannot be crossloaded." ) else: - self.crossload_group(self.configure_group, configcog.configure_group) + self.crossload_group(self.configure_group, configcog.config_group) + self.crossload_group(self.admin_group, configcog.admin_group) # ----- Cog API ----- async def absent_remove_role(self, guildid, userid, roleid): @@ -55,6 +85,12 @@ class MemberAdminCog(LionCog): """ return await self.data.past_roles.delete_where(guildid=guildid, userid=userid, roleid=roleid) + def data_bucket_req(self, guildid: int): + bucket = self._data_request_buckets.get(guildid, None) + if bucket is None: + bucket = self._data_request_buckets[guildid] = Bucket(10, 10) + bucket.request() + # ----- Event Handlers ----- @LionCog.listener('on_member_join') @log_wrap(action="Greetings") @@ -320,7 +356,15 @@ class MemberAdminCog(LionCog): ) # ----- Cog Commands ----- - @cmds.hybrid_command( + @LionCog.placeholder_group + @cmds.hybrid_group('admin', with_app_command=False) + async def admin_group(self, ctx: LionContext): + """ + Substitute configure command group. + """ + pass + + @admin_group.command( name=_p('cmd:resetmember', "resetmember"), description=_p( 'cmd:resetmember|desc', @@ -342,7 +386,6 @@ class MemberAdminCog(LionCog): ), ) @high_management_ward - @appcmds.default_permissions(administrator=True) async def cmd_resetmember(self, ctx: LionContext, target: discord.User, saved_roles: Optional[bool] = False, @@ -378,6 +421,214 @@ class MemberAdminCog(LionCog): ephemeral=True ) + @admin_group.command( + name=_p('cmd:admin_data', "data"), + description=_p( + 'cmd:admin_data|desc', + "Download various raw data for external analysis and backup." + ) + ) + @appcmds.rename( + data_type=_p('cmd:admin_data|param:data_type', "type"), + target=_p('cmd:admin_data|param:target', "target"), + start=_p('cmd:admin_data|param:start', "after"), + end=_p('cmd:admin_data|param:end', "before"), + limit=_p('cmd:admin_data|param:limit', "limit"), + ) + @appcmds.describe( + data_type=_p( + 'cmd:admin_data|param:data_type|desc', + "Select the type of data you want to download" + ), + target=_p( + 'cmd:admin_data|param:target|desc', + "Filter the data by selecting a user or role" + ), + start=_p( + 'cmd:admin_data|param:start|desc', + "Retrieve records created after this date and time in server timezone (YYYY-MM-DD HH:MM)" + ), + end=_p( + 'cmd:admin_data|param:end|desc', + "Retrieve records created before this date and time in server timezone (YYYY-MM-DD HH:MM)" + ), + limit=_p( + 'cmd:admin_data|param:limit|desc', + "Maximum number of records to retrieve." + ) + ) + @high_management_ward + async def cmd_data(self, ctx: LionContext, + data_type: Transformed[DownloadableData, AppCommandOptionType.string], + target: Optional[discord.User | discord.Member | discord.Role] = None, + start: Optional[str] = None, + end: Optional[str] = None, + limit: appcmds.Range[int, 1, 100000] = 1000, + ): + if not ctx.guild: + return + if not ctx.interaction: + return + + t = self.bot.translator.t + + # Parse arguments + + userids: Optional[list[int]] = None + if target is None: + # All guild members + userids = None + elif isinstance(target, discord.Role): + # Members of the given role + userids = [member.id for member in target.members] + else: + # target is a user or member + userids = [target.id] + + if start: + start_time = await parse_time_static(start, ctx.lguild.timezone) + else: + start_time = ctx.guild.created_at + + if end: + end_time = await parse_time_static(end, ctx.lguild.timezone) + else: + end_time = utc_now() + + # Form query + if data_type is DownloadableData.VOICE_LEADERBOARD: + query = self.bot.core.data.Member.table.select_where() + query.select( + 'guildid', + 'userid', + total_time=RawExpr( + sql.SQL("study_time_between(guildid, userid, %s, %s)"), + (start_time, end_time) + ) + ) + query.order_by('total_time', ORDER.DESC, NULLS.LAST) + elif data_type is DownloadableData.MSG_LEADERBOARD: + from tracking.text.data import TextTrackerData as Data + + query = Data.TextSessions.table.select_where() + query.select( + 'guildid', + 'userid', + total_messages="SUM(messages)" + ) + query.where( + Data.TextSessions.start_time >= start_time, + Data.TextSessions.start_time < end_time, + ) + query.group_by('guildid', 'userid') + query.order_by('total_messages', ORDER.DESC, NULLS.LAST) + elif data_type is DownloadableData.XP_LEADERBOARD: + from modules.statistics.data import StatsData as Data + + query = Data.MemberExp.table.select_where() + query.select( + 'guildid', + 'userid', + total_xp="SUM(amount)" + ) + query.where( + Data.MemberExp.earned_at >= start_time, + Data.MemberExp.earned_at < end_time, + ) + query.group_by('guildid', 'userid') + query.order_by('total_xp', ORDER.DESC, NULLS.LAST) + elif data_type is DownloadableData.ROLEMENU_EQUIP: + from modules.rolemenus.data import RoleMenuData as Data + + query = Data.RoleMenuHistory.table.select_where().leftjoin('role_menus', using=('menuid',)) + query.select( + guildid=Data.RoleMenu.guildid, + userid=Data.RoleMenuHistory.userid, + menuid=Data.RoleMenu.menuid, + menu_messageid=Data.RoleMenu.messageid, + menu_name=Data.RoleMenu.name, + equipid=Data.RoleMenuHistory.equipid, + roleid=Data.RoleMenuHistory.roleid, + obtained_at=Data.RoleMenuHistory.obtained_at, + expires_at=Data.RoleMenuHistory.expires_at, + removed_at=Data.RoleMenuHistory.removed_at, + transactionid=Data.RoleMenuHistory.transactionid, + ) + query.where( + Data.RoleMenuHistory.obtained_at >= start_time, + Data.RoleMenuHistory.obtained_at < end_time, + ) + query.order_by(Data.RoleMenuHistory.obtained_at, ORDER.DESC) + elif data_type is DownloadableData.TRANSACTIONS: + raise SafeCancellation("Transaction data is not yet available") + elif data_type is DownloadableData.BALANCES: + raise SafeCancellation("Member balance data is not yet available") + elif data_type is DownloadableData.VOICE_SESSIONS: + raise SafeCancellation("Raw voice session data is not yet available") + else: + raise ValueError(f"Unknown data type requested {data_type}") + + query.where(guildid=ctx.guild.id) + if userids: + query.where(userid=userids) + query.limit(limit) + query.with_no_adapter() + + # Request bucket + try: + self.data_bucket_req(ctx.guild.id) + except BucketOverFull: + # Don't do anything, even respond to the interaction + raise SafeCancellation() + except BucketFull: + raise SafeCancellation(t(_p( + 'cmd:admin_data|error:ratelimited', + "Too many requests! Please wait a few minutes before using this command again." + ))) + + # Run query + await ctx.interaction.response.defer(thinking=True) + results = await query + + if results: + with StringIO() as stream: + write_records(results, stream) + stream.seek(0) + file = discord.File(stream, filename='data.csv') + await ctx.reply(file=file) + else: + await ctx.error_reply( + t(_p( + 'cmd:admin_data|error:no_results', + "Your query had no results! Try relaxing your filters." + )) + ) + + @cmd_data.autocomplete('start') + @cmd_data.autocomplete('end') + async def cmd_data_acmpl_time(self, interaction: discord.Interaction, partial: str): + if not interaction.guild: + return [] + + lguild = await self.bot.core.lions.fetch_guild(interaction.guild.id) + timezone = lguild.timezone + + t = self.bot.translator.t + try: + timestamp = await parse_time_static(partial, timezone) + choice = appcmds.Choice( + name=timestamp.strftime('%Y-%m-%d %H:%M'), + value=partial + ) + except UserInputError: + choice = appcmds.Choice( + name=t(_p( + 'cmd:admin_data|acmpl:time|error:parse', + "Cannot parse \"{partial}\" as a time. Try the format YYYY-MM-DD HH:MM" + )).format(partial=partial)[:100], + value=partial + ) + return [choice] # ----- Config Commands ----- @LionCog.placeholder_group diff --git a/src/modules/member_admin/settings.py b/src/modules/member_admin/settings.py index 057a202c..bbdb200b 100644 --- a/src/modules/member_admin/settings.py +++ b/src/modules/member_admin/settings.py @@ -9,6 +9,7 @@ from settings import ListData, ModelData from settings.groups import SettingGroup from settings.setting_types import BoolSetting, ChannelSetting, RoleListSetting from utils.lib import recurse_map, replace_multiple, tabulate +from wards import low_management_iward, high_management_iward from . import babel from .data import MemberAdminData @@ -36,6 +37,7 @@ _greeting_subkey_desc = { class MemberAdminSettings(SettingGroup): class GreetingChannel(ModelData, ChannelSetting): setting_id = 'greeting_channel' + _write_ward = low_management_iward _display_name = _p('guildset:greeting_channel', "welcome_channel") _desc = _p( @@ -87,6 +89,7 @@ class MemberAdminSettings(SettingGroup): class GreetingMessage(ModelData, MessageSetting): setting_id = 'greeting_message' + _write_ward = low_management_iward _display_name = _p( 'guildset:greeting_message', "welcome_message" @@ -209,6 +212,7 @@ class MemberAdminSettings(SettingGroup): class ReturningMessage(ModelData, MessageSetting): setting_id = 'returning_message' + _write_ward = low_management_iward _display_name = _p( 'guildset:returning_message', "returning_message" @@ -335,6 +339,7 @@ class MemberAdminSettings(SettingGroup): class Autoroles(ListData, RoleListSetting): setting_id = 'autoroles' + _write_ward = high_management_iward _display_name = _p( 'guildset:autoroles', "autoroles" @@ -357,6 +362,7 @@ class MemberAdminSettings(SettingGroup): class BotAutoroles(ListData, RoleListSetting): setting_id = 'bot_autoroles' + _write_ward = high_management_iward _display_name = _p( 'guildset:bot_autoroles', "bot_autoroles" @@ -379,6 +385,7 @@ class MemberAdminSettings(SettingGroup): class RolePersistence(ModelData, BoolSetting): setting_id = 'role_persistence' _event = 'guildset_role_persistence' + _write_ward = low_management_iward _display_name = _p('guildset:role_persistence', "role_persistence") _desc = _p( diff --git a/src/modules/member_admin/settingui.py b/src/modules/member_admin/settingui.py index 91e0e93e..b6845827 100644 --- a/src/modules/member_admin/settingui.py +++ b/src/modules/member_admin/settingui.py @@ -45,6 +45,7 @@ class MemberAdminUI(ConfigUI): """ await selection.response.defer(thinking=True, ephemeral=True) setting = self.get_instance(Settings.GreetingChannel) + await setting.interaction_check(setting.parent_id, selection) setting.value = selected.values[0] if selected.values else None await setting.write() await selection.delete_original_response() @@ -73,6 +74,7 @@ class MemberAdminUI(ConfigUI): await equippable_role(self.bot, role, selection.user) setting = self.get_instance(Settings.Autoroles) + await setting.interaction_check(setting.parent_id, selection) setting.value = selected.values await setting.write() # Instance hooks will update the menu @@ -102,6 +104,7 @@ class MemberAdminUI(ConfigUI): await equippable_role(self.bot, role, selection.user) setting = self.get_instance(Settings.BotAutoroles) + await setting.interaction_check(setting.parent_id, selection) setting.value = selected.values await setting.write() # Instance hooks will update the menu @@ -131,6 +134,7 @@ class MemberAdminUI(ConfigUI): await press.response.defer(thinking=True, ephemeral=True) t = self.bot.translator.t setting = self.get_instance(Settings.GreetingMessage) + await setting.interaction_check(setting.parent_id, press) value = setting.value if value is None: @@ -173,6 +177,7 @@ class MemberAdminUI(ConfigUI): await press.response.defer(thinking=True, ephemeral=True) t = self.bot.translator.t setting = self.get_instance(Settings.ReturningMessage) + await setting.interaction_check(setting.parent_id, press) greeting = self.get_instance(Settings.GreetingMessage) value = setting.value @@ -254,7 +259,7 @@ class MemberAdminUI(ConfigUI): class MemberAdminDashboard(DashboardSection): section_name = _p( "dash:member_admin|title", - "Greetings and Initial Roles ({commands[configure welcome]})" + "Greetings and Initial Roles ({commands[config welcome]})" ) _option_name = _p( "dash:member_admin|dropdown|placeholder", @@ -278,7 +283,7 @@ class MemberAdminDashboard(DashboardSection): page.add_field( name=t(_p( 'dash:member_admin|section:greeting_messages|name', - "Greeting Messages ({commands[configure welcome]})" + "Greeting Messages ({commands[admin config welcome]})" )).format(commands=self.bot.core.mention_cache), value=table, inline=False @@ -289,7 +294,7 @@ class MemberAdminDashboard(DashboardSection): page.add_field( name=t(_p( 'dash:member_admin|section:initial_roles|name', - "Initial Roles ({commands[configure welcome]})" + "Initial Roles ({commands[admin config welcome]})" )).format(commands=self.bot.core.mention_cache), value=table, inline=False diff --git a/src/modules/meta/cog.py b/src/modules/meta/cog.py index b865148d..715d0ec5 100644 --- a/src/modules/meta/cog.py +++ b/src/modules/meta/cog.py @@ -1,19 +1,36 @@ from typing import Optional +import gc +import sys import asyncio +import logging import discord from discord.ext import commands as cmds from discord import app_commands as appcmds +from data.queries import ORDER +from utils.lib import tabulate from wards import low_management from meta import LionBot, LionCog, LionContext +from data import Table from utils.ui import AButton, AsComponents +from utils.lib import utc_now from . import babel from .helpui import HelpUI _p = babel._p +logger = logging.getLogger(__name__) + + +created = utc_now() +guide_link = "https://discord.studylions.com/tutorial" + +animation_link = ( + "https://media.discordapp.net/attachments/879412267731542047/926837189814419486/ezgif.com-resize.gif" +) + class MetaCog(LionCog): def __init__(self, bot: LionBot): @@ -27,6 +44,8 @@ class MetaCog(LionCog): ) ) async def help_cmd(self, ctx: LionContext): + if not ctx.interaction: + return await ctx.interaction.response.defer(thinking=True, ephemeral=True) ui = HelpUI( ctx.bot, @@ -35,3 +54,342 @@ class MetaCog(LionCog): show_admin=await low_management(ctx.bot, ctx.author, ctx.guild), ) await ui.run(ctx.interaction) + + @LionCog.listener('on_guild_join') + async def post_join_message(self, guild: discord.Guild): + logger.debug(f"Sending join message to ") + # Send join message + t = self.bot.translator.t + message = t(_p( + 'new_guild_join_message|desc', + "Thank you for inviting me to your community!\n" + "Get started by typing {help_cmd} to see my commands," + " and {dash_cmd} to view and set up my configuration options!\n\n" + "If you need any help configuring me," + " or would like to suggest a feature," + " report a bug, and stay updated," + " make sure to join our main support server by [clicking here]({support})." + )).format( + dash_cmd=self.bot.core.mention_cmd('dashboard'), + help_cmd=self.bot.core.mention_cmd('help'), + support=self.bot.config.bot.support_guild, + ) + try: + await guild.me.edit(nick="Leo") + except discord.HTTPException: + pass + if (channel := guild.system_channel) and channel.permissions_for(guild.me).embed_links: + embed = discord.Embed( + description=message, + colour=discord.Colour.orange(), + ) + embed.set_author( + name=t(_p( + 'new_guild_join_message|name', + "Hello everyone! My name is Leo, the LionBot!" + )), + icon_url="https://cdn.discordapp.com/emojis/933610591459872868.webp" + ) + embed.set_image(url=animation_link) + + try: + await channel.send(embed=embed) + except discord.HTTPException: + logger.warning( + f"Could not send join message to ", + exc_info=True, + ) + + @cmds.hybrid_command( + name=_p('cmd:invite', "invite"), + description=_p( + 'cmd:invite|desc', + "Invite LionBot to your own server." + ) + ) + async def invite_cmd(self, ctx: LionContext): + t = self.bot.translator.t + + embed = discord.Embed( + colour=discord.Colour.orange(), + description=t(_p( + 'cmd:invite|embed|desc', + "[Click here]({invite_link}) to add me to your server." + )).format( + invite_link=self.bot.config.bot.invite_bot, + ) + ) + embed.add_field( + name=t(_p( + 'cmd:invite|embed|field:tips|name', + "Setup Tips" + )), + value=t(_p( + 'cmd:invite|embed|field:tips|value', + "Remember to check out {help_cmd} for the important command list," + " including the admin page which displays the hidden admin-level" + " configuration commands like {dashboard}!\n" + "Also, if you have any issues or questions," + " you can join our [support server]({support_link}) to talk to our friendly" + " support team!" + )).format( + help_cmd=self.bot.core.mention_cmd('help'), + dashboard=self.bot.core.mention_cmd('dashboard'), + support_link=self.bot.config.bot.support_guild, + ) + ) + await ctx.reply(embed=embed, ephemeral=True) + + @cmds.hybrid_command( + name=_p('cmd:support', "support"), + description=_p( + 'cmd:support|desc', + "Have an issue or a question? Speak to my friendly support team here." + ) + ) + async def support_cmd(self, ctx: LionContext): + t = self.bot.translator.t + await ctx.reply( + t(_p( + 'cmd:support|response', + "Speak to my friendly support team by joining this server and making a ticket" + " in the support channel!\n" + "{support_link}" + )).format(support_link=self.bot.config.bot.support_guild), + ephemeral=True, + ) + + @cmds.hybrid_command( + name=_p('cmd:nerd', "nerd"), + description=_p( + 'cmd:nerd|desc', + "View hidden details and statistics about me ('nerd statistics')", + ) + ) + async def nerd_cmd(self, ctx: LionContext): + t = self.bot.translator.t + + if ctx.interaction: + await ctx.interaction.response.defer(thinking=True) + + embed = discord.Embed( + colour=discord.Colour.orange(), + title=t(_p( + 'cmd:nerd|title', + "Nerd Statistics" + )), + ) + if ctx.guild: + embed.set_footer( + text=f"Your guildid: {ctx.guild.id}" + ) + else: + embed.set_footer( + text="Sent from direct message" + ) + + # Bot Stats + bot_stats_lines = [] + + # Currently {n} people active in {m} rooms of {n} guilds + query = await Table('voice_sessions_ongoing').bind(self.bot.db).select_one_where( + ).select( + total_users='COUNT(userid)', + total_rooms='COUNT(channelid)', + total_guilds='COUNT(guildid)', + ) + bot_stats_lines.append(( + t(_p('cmd:nerd|field:currently|name', "Currently")), + t(_p( + 'cmd:nerd|field:currently|value', + "`{people}` people active in `{rooms}` rooms of `{guilds}` guilds." + )).format( + people=query['total_users'], + rooms=query['total_rooms'], + guilds=query['total_guilds'] + ) + )) + + # Recorded {h} voice hours from {n} people across {n} sessions + query = await Table('voice_sessions').bind(self.bot.db).select_one_where( + ).select( + total_hours='SUM(duration) / 3600', + total_users='COUNT(userid)', + total_sessions='COUNT(*)', + ) + bot_stats_lines.append(( + t(_p('cmd:nerd|field:recorded|name', "Recorded")), + t(_p( + 'cmd:nerd|field:recorded|value', + "`{hours}` voice hours from `{users}` people across `{sessions}` sessions." + )).format( + hours=query['total_hours'], + users=query['total_users'], + sessions=query['total_sessions'], + ) + )) + + # Registered {n} users and {m} guilds + query1 = await Table('user_config').bind(self.bot.db).select_one_where( + ).select(total_users='COUNT(*)') + query2 = await Table('guild_config').bind(self.bot.db).select_one_where( + ).select(total_guilds='COUNT(*)') + bot_stats_lines.append(( + t(_p('cmd:nerd|field:registered|name', "Registered")), + t(_p( + 'cmd:nerd|field:registered|value', + "`{users}` users and `{guilds}` guilds." + )).format( + users=query1['total_users'], + guilds=query2['total_guilds'], + ) + )) + + # {n} tasks completed out of {m} + query = await Table('tasklist').bind(self.bot.db).select_one_where( + ).select( + total_tasks='COUNT(*)', + total_completed='COUNT(*) filter (WHERE completed_at IS NOT NULL)', + ) + bot_stats_lines.append(( + t(_p('cmd:nerd|field:tasks|name', "Tasks")), + t(_p( + 'cmd:nerd|field:tasks|value', + "`{tasks}` tasks completed out of `{total}`." + )).format( + tasks=query['total_completed'], total=query['total_tasks'] + ) + )) + + # {m} timers running across {n} guilds + query = await Table('timers').bind(self.bot.db).select_one_where( + ).select( + total_timers='COUNT(*)', + guilds='COUNT(guildid)' + ) + bot_stats_lines.append(( + t(_p('cmd:nerd|field:timers|name', "Timers")), + t(_p( + 'cmd:nerd|field:timers|value', + "`{timers}` timers running across `{guilds}` guilds." + )).format( + timers=query['total_timers'], + guilds=query['guilds'], + ) + )) + + bot_stats_section = '\n'.join(tabulate(*bot_stats_lines)) + embed.add_field( + name=t(_p('cmd:nerd|section:bot_stats|name', "Bot Stats")), + value=bot_stats_section, + inline=False, + ) + + # ----- Process ----- + process_lines = [] + + # Shard {n} of {n} + process_lines.append(( + t(_p('cmd:nerd|field:shard|name', "Shard")), + t(_p( + 'cmd:nerd|field:shard|value', + "`{shard_number}` of `{shard_count}`" + )).format(shard_number=self.bot.shard_id, shard_count=self.bot.shard_count) + )) + + # Guilds + process_lines.append(( + t(_p('cmd:nerd|field:guilds|name', "Guilds")), + t(_p( + 'cmd:nerd|field:guilds|value', + "`{guilds}` guilds with `{count}` total members." + )).format( + guilds=len(self.bot.guilds), + count=sum(guild.member_count or 0 for guild in self.bot.guilds) + ) + )) + + # Version + version = await self.bot.db.version() + process_lines.append(( + t(_p('cmd:nerd|field:version|name', "Leo Version")), + t(_p( + 'cmd:nerd|field:version|value', + "`v{version}`, last updated {timestamp} from `{reason}`." + )).format( + version=version.version, + timestamp=discord.utils.format_dt(version.time, 'D'), + reason=version.author, + ) + )) + + # Py version + py_version = sys.version.split()[0] + dpy_version = discord.__version__ + process_lines.append(( + t(_p('cmd:nerd|field:py_version|name', "Py Version")), + t(_p( + 'cmd:nerd|field:py_version|value', + "`{py_version}` running discord.py `{dpy_version}`" + )).format( + py_version=py_version, dpy_version=dpy_version, + ) + )) + + process_section = '\n'.join(tabulate(*process_lines)) + embed.add_field( + name=t(_p('cmd:nerd|section:process_section|name', "Process")), + value=process_section, + inline=False, + ) + + # ----- Shard Statistics ----- + shard_lines = [] + + # Handling `n` events + shard_lines.append(( + t(_p('cmd:nerd|field:handling|name', "Handling")), + t(_p( + 'cmd:nerd|field:handling|name', + "`{events}` active commands and events." + )).format( + events=len(self.bot._running_events) + ), + )) + + # Working on n background tasks + shard_lines.append(( + t(_p('cmd:nerd|field:working|name', "Working On")), + t(_p( + 'cmd:nerd|field:working|value', + "`{tasks}` background tasks." + )).format(tasks=len(asyncio.all_tasks())) + )) + + # Count objects in memory + shard_lines.append(( + t(_p('cmd:nerd|field:objects|name', "Objects")), + t(_p( + 'cmd:nerd|field:objects|value', + "`{objects}` loaded in memory." + )).format(objects=gc.get_count()) + )) + + # Uptime + uptime = int((utc_now() - created).total_seconds()) + uptimestr = ( + f"`{uptime // (24 * 3600)}` days, `{uptime // 3600 % 24:02}:{uptime // 60 % 60:02}:{uptime % 60:02}`" + ) + shard_lines.append(( + t(_p('cmd:nerd|field:uptime|name', "Uptime")), + uptimestr, + )) + + shard_section = '\n'.join(tabulate(*shard_lines)) + embed.add_field( + name=t(_p('cmd:nerd|section:shard_section|name', "Shard Statistics")), + value=shard_section, + inline=False, + ) + + await ctx.reply(embed=embed) diff --git a/src/modules/meta/help_sections.py b/src/modules/meta/help_sections.py index b5c19672..0d9f917c 100644 --- a/src/modules/meta/help_sections.py +++ b/src/modules/meta/help_sections.py @@ -74,8 +74,8 @@ admin_extra = _p( Use {cmd_dashboard} to see an overview of the server configuration, \ and quickly jump to the feature configuration panels to modify settings. - Configuration panels are also accessible directly through the `/configure` commands \ - and most features may be configured through these commands. + Most settings may also be directly set through the `/config` and `/admin config` commands, \ + depending on whether the settings require moderator (manage server) or admin level permissions, respectively. Other relevant commands for guild configuration below: `/editshop`: Add/Edit/Remove colour roles from the {coin} shop. diff --git a/src/modules/moderation/cog.py b/src/modules/moderation/cog.py index 905766ec..43475316 100644 --- a/src/modules/moderation/cog.py +++ b/src/modules/moderation/cog.py @@ -5,22 +5,27 @@ import asyncio import discord from discord.ext import commands as cmds from discord import app_commands as appcmds +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 meta.sharding import THIS_SHARD from core.data import CoreData -from utils.lib import utc_now +from utils.lib import utc_now, parse_ranges, parse_time_static +from utils.ui import input -from wards import low_management_ward, high_management_ward, equippable_role +from wards import low_management_ward, high_management_ward, equippable_role, moderator_ward from . import babel, logger from .data import ModerationData, TicketType, TicketState from .settings import ModerationSettings from .settingui import ModerationSettingUI from .ticket import Ticket +from .tickets import NoteTicket, WarnTicket +from .ticketui import TicketListUI, TicketFilter -_p = babel._p +_p, _np = babel._p, babel._np class ModerationCog(LionCog): @@ -51,7 +56,7 @@ class ModerationCog(LionCog): "Moderation configuration will not crossload." ) else: - self.crossload_group(self.configure_group, configcog.configure_group) + self.crossload_group(self.configure_group, configcog.admin_config_group) if self.bot.is_ready(): await self.initialise() @@ -125,6 +130,447 @@ class ModerationCog(LionCog): ... # ----- Commands ----- + # modnote command + @cmds.hybrid_command( + name=_p('cmd:modnote', "modnote"), + description=_p( + 'cmd:modnote|desc', + "Add a note to the target member's moderation record." + ) + ) + @appcmds.rename( + target=_p('cmd:modnote|param:target', "target"), + note=_p('cmd:modnote|param:note', "note"), + ) + @appcmds.describe( + target=_p( + 'cmd:modnote|param:target|desc', + "Target member or user to add a note to." + ), + note=_p( + 'cmd:modnote|param:note|desc', + "Contents of the note." + ), + ) + @appcmds.default_permissions(manage_guild=True) + @appcmds.guild_only + @moderator_ward + async def cmd_modnote(self, ctx: LionContext, + target: discord.Member | discord.User, + note: Optional[appcmds.Range[str, 1, 1024]] = None, + ): + """ + Create a NoteTicket on the given target. + + If `note` is not given, prompts for the note content via modal. + """ + if not ctx.guild: + return + if not ctx.interaction: + return + t = self.bot.translator.t + + if note is None: + # Prompt for note via modal + modal_title = t(_p( + 'cmd:modnote|modal:enter_note|title', + "Moderation Note" + )) + input_field = TextInput( + label=t(_p( + 'cmd:modnote|modal:enter_note|field|label', + "Note Content", + )), + style=TextStyle.long, + min_length=1, + max_length=1024, + ) + try: + interaction, note = await input( + ctx.interaction, modal_title, + field=input_field, + timeout=300 + ) + except asyncio.TimeoutError: + # Moderator did not fill in the modal in time + # Just leave quietly + raise SafeCancellation + else: + interaction = ctx.interaction + + await interaction.response.defer(thinking=True, ephemeral=True) + + # Create NoteTicket + ticket = await NoteTicket.create( + bot=self.bot, + guildid=ctx.guild.id, userid=target.id, + moderatorid=ctx.author.id, content=note, expiry=None + ) + + # Write confirmation with ticket number and link to ticket if relevant + embed = discord.Embed( + colour=discord.Colour.orange(), + description=t(_p( + 'cmd:modnote|embed:success|desc', + "Moderation note created as [Ticket #{ticket}]({jump_link})" + )).format( + ticket=ticket.data.guild_ticketid, + jump_link=ticket.jump_url or ctx.message.jump_url + ) + ) + await interaction.edit_original_response(embed=embed) + + # Warning Ticket Command + @cmds.hybrid_command( + name=_p('cmd:warning', "warning"), + description=_p( + 'cmd:warning|desc', + "Warn a member for a misdemeanour, and add it to their moderation record." + ) + ) + @appcmds.rename( + target=_p('cmd:warning|param:target', "target"), + reason=_p('cmd:warning|param:reason', "reason"), + ) + @appcmds.describe( + target=_p( + 'cmd:warning|param:target|desc', + "Target member to warn." + ), + reason=_p( + 'cmd:warning|param:reason|desc', + "The reason why you are warning this member." + ), + ) + @appcmds.default_permissions(manage_guild=True) + @appcmds.guild_only + @moderator_ward + async def cmd_warning(self, ctx: LionContext, + target: discord.Member, + reason: Optional[appcmds.Range[str, 0, 1024]] = None, + ): + if not ctx.guild: + return + if not ctx.interaction: + return + t = self.bot.translator.t + + # Prompt for warning reason if not given + if reason is None: + modal_title = t(_p( + 'cmd:warning|modal:reason|title', + "Moderation Warning" + )) + input_field = TextInput( + label=t(_p( + 'cmd:warning|modal:reason|field|label', + "Reason for the warning (visible to user)." + )), + style=TextStyle.long, + min_length=0, + max_length=1024, + ) + try: + interaction, note = await input( + ctx.interaction, modal_title, + field=input_field, + timeout=300, + ) + except asyncio.TimeoutError: + raise SafeCancellation + else: + interaction = ctx.interaction + + await interaction.response.defer(thinking=True, ephemeral=False) + + # Create WarnTicket + ticket = await WarnTicket.create( + bot=self.bot, + guildid=ctx.guild.id, userid=target.id, + moderatorid=ctx.author.id, content=reason + ) + + # Post to user or moderation notify channel + alert_embed = discord.Embed( + colour=discord.Colour.dark_red(), + title=t(_p( + 'cmd:warning|embed:user_alert|title', + "You have received a warning!" + )), + description=reason, + ) + alert_embed.add_field( + name=t(_p( + 'cmd:warning|embed:user_alert|field:note|name', + "Note" + )), + value=t(_p( + 'cmd:warning|embed:user_alert|field:note|value', + "*Warnings appear in your moderation history." + " Continuing failure to comply with server rules and moderator" + " directions may result in more severe action." + )) + ) + alert_embed.set_footer( + icon_url=ctx.guild.icon, + text=ctx.guild.name, + ) + alert = await self.send_alert(target, embed=alert_embed) + + # Ack the ticket creation, including alert status and warning count + + warning_count = await ticket.count_warnings_for( + self.bot, ctx.guild.id, target.id + ) + count_line = t(_np( + 'cmd:warning|embed:success|line:count', + "This their first warning.", + "They have recieved **`{count}`** warnings.", + warning_count + )).format(count=warning_count) + + embed = discord.Embed( + colour=discord.Colour.orange(), + description=t(_p( + 'cmd:warning|embed:success|desc', + "[Ticket #{ticket}]({jump_link}) {user} has been warned." + )).format( + ticket=ticket.data.guild_ticketid, + jump_link=ticket.jump_url or ctx.message.jump_url, + user=target.mention, + ) + '\n' + count_line + ) + if alert is None: + embed.add_field( + name=t(_p( + 'cmd:warning|embed:success|field:no_alert|name', + "Note" + )), + value=t(_p( + 'cmd:warning|embed:success|field:no_alert|value', + "*Could not deliver warning to the target.*" + )) + ) + await interaction.edit_original_response(embed=embed) + + # Pardon user command + @cmds.hybrid_command( + name=_p('cmd:pardon', "pardon"), + description=_p( + 'cmd:pardon|desc', + "Pardon moderation tickets to mark them as no longer in effect." + ) + ) + @appcmds.rename( + ticketids=_p( + 'cmd:pardon|param:ticketids', + "tickets" + ), + reason=_p( + 'cmd:pardon|param:reason', + "reason" + ) + ) + @appcmds.describe( + ticketids=_p( + 'cmd:pardon|param:ticketids|desc', + "Comma separated list of ticket numbers to pardon." + ), + reason=_p( + 'cmd:pardon|param:reason', + "Why these tickets are being pardoned." + ) + ) + @appcmds.default_permissions(manage_guild=True) + @appcmds.guild_only + @moderator_ward + async def cmd_pardon(self, ctx: LionContext, + ticketids: str, + reason: Optional[appcmds.Range[str, 0, 1024]] = None, + ): + if not ctx.guild: + return + if not ctx.interaction: + return + t = self.bot.translator.t + + # Prompt for pardon reason if not given + # Note we can't parse first since we need to do first response with the modal + if reason is None: + modal_title = t(_p( + 'cmd:pardon|modal:reason|title', + "Pardon Tickets" + )) + input_field = TextInput( + label=t(_p( + 'cmd:pardon|modal:reason|field|label', + "Why are you pardoning these tickets?" + )), + style=TextStyle.long, + min_length=0, + max_length=1024, + ) + try: + interaction, reason = await input( + ctx.interaction, modal_title, field=input_field, timeout=300, + ) + except asyncio.TimeoutError: + raise SafeCancellation + else: + interaction = ctx.interaction + + await interaction.response.defer(thinking=True) + + # Parse provided ticketids + try: + parsed_ids = parse_ranges(ticketids) + errored = False + except ValueError: + errored = True + parsed_ids = [] + + if errored or not parsed_ids: + raise UserInputError(t(_p( + 'cmd:pardon|error:parse_ticketids', + "Could not parse provided tickets as a list of ticket ids!" + " Please enter tickets as a comma separated list of ticket numbers," + " for example `1, 2, 3`." + ))) + + # Now find these tickets + tickets = await Ticket.fetch_tickets( + bot=self.bot, + guildid=ctx.guild.id, + guild_ticketid=parsed_ids, + ) + if not tickets: + raise UserInputError(t(_p( + 'cmd:pardon|error:no_matching', + "No matching moderation tickets found to pardon!" + ))) + + # Pardon each ticket + for ticket in tickets: + await ticket.pardon( + modid=ctx.author.id, + reason=reason + ) + + # Now ack the pardon + count = len(tickets) + ticketstr = ', '.join( + f"[#{ticket.data.guild_ticketid}]({ticket.jump_url})" for ticket in tickets + ) + + embed = discord.Embed( + colour=discord.Colour.brand_green(), + description=t(_np( + 'cmd:pardon|embed:success|title', + "Ticket {ticketstr} has been pardoned.", + "The following tickets have been pardoned:\n{ticketstr}", + count + )).format(ticketstr=ticketstr) + ) + await interaction.edit_original_response(embed=embed) + + # View tickets + @cmds.hybrid_command( + name=_p('cmd:tickets', "tickets"), + description=_p( + 'cmd:tickets|desc', + "View moderation tickets in this server." + ) + ) + @appcmds.rename( + target_user=_p('cmd:tickets|param:target', "target"), + ticket_type=_p('cmd:tickets|param:type', "type"), + ticket_state=_p('cmd:tickets|param:state', "ticket_state"), + include_pardoned=_p('cmd:tickets|param:pardoned', "include_pardoned"), + acting_moderator=_p('cmd:tickets|param:moderator', "acting_moderator"), + after=_p('cmd:tickets|param:after', "after"), + before=_p('cmd:tickets|param:before', "before"), + ) + @appcmds.describe( + target_user=_p( + 'cmd:tickets|param:target|desc', + "Filter by tickets acting on a given user." + ), + ticket_type=_p( + 'cmd:tickets|param:type|desc', + "Filter by ticket type." + ), + ticket_state=_p( + 'cmd:tickets|param:state|desc', + "Filter by ticket state." + ), + include_pardoned=_p( + 'cmd:tickets|param:pardoned|desc', + "Whether to only show active tickets, or also include pardoned." + ), + acting_moderator=_p( + 'cmd:tickets|param:moderator|desc', + "Filter by moderator responsible for the ticket." + ), + after=_p( + 'cmd:tickets|param:after|desc', + "Only show tickets after this date (YYY-MM-DD HH:MM)" + ), + before=_p( + 'cmd:tickets|param:before|desc', + "Only show tickets before this date (YYY-MM-DD HH:MM)" + ), + ) + @appcmds.choices( + ticket_type=[ + appcmds.Choice(name=typ.name, value=typ.name) + for typ in (TicketType.NOTE, TicketType.WARNING, TicketType.STUDY_BAN) + ], + ticket_state=[ + appcmds.Choice(name=state.name, value=state.name) + for state in ( + TicketState.OPEN, TicketState.EXPIRING, TicketState.EXPIRED, TicketState.PARDONED, + ) + ] + ) + @appcmds.default_permissions(manage_guild=True) + @appcmds.guild_only + @moderator_ward + async def tickets_cmd(self, ctx: LionContext, + target_user: Optional[discord.User] = None, + ticket_type: Optional[appcmds.Choice[str]] = None, + ticket_state: Optional[appcmds.Choice[str]] = None, + include_pardoned: Optional[bool] = None, + acting_moderator: Optional[discord.User] = None, + after: Optional[str] = None, + before: Optional[str] = None, + ): + if not ctx.guild: + return + if not ctx.interaction: + return + + filters = TicketFilter(self.bot) + if target_user is not None: + filters.targetids = [target_user.id] + if ticket_type is not None: + filters.types = [TicketType[ticket_type.value]] + if ticket_state is not None: + filters.states = [TicketState[ticket_state.value]] + elif include_pardoned: + filters.states = None + else: + filters.states = [TicketState.OPEN, TicketState.EXPIRING] + if acting_moderator is not None: + filters.moderatorids = [acting_moderator.id] + if after is not None: + filters.after = await parse_time_static(after, ctx.lguild.timezone) + if before is not None: + filters.before = await parse_time_static(before, ctx.lguild.timezone) + + + ticketsui = TicketListUI(self.bot, ctx.guild, ctx.author.id, filters=filters) + await ticketsui.run(ctx.interaction) + await ticketsui.wait() # ----- Configuration ----- @LionCog.placeholder_group @@ -140,12 +586,13 @@ class ModerationCog(LionCog): ) ) @appcmds.rename( + adminrole=ModerationSettings.AdminRole._display_name, modrole=ModerationSettings.ModRole._display_name, ticket_log=ModerationSettings.TicketLog._display_name, alert_channel=ModerationSettings.AlertChannel._display_name, ) @appcmds.describe( - modrole=ModerationSettings.ModRole._desc, + adminrole=ModerationSettings.AdminRole._desc, ticket_log=ModerationSettings.TicketLog._desc, alert_channel=ModerationSettings.AlertChannel._desc, ) @@ -154,6 +601,7 @@ class ModerationCog(LionCog): modrole: Optional[discord.Role] = None, ticket_log: Optional[discord.TextChannel] = None, alert_channel: Optional[discord.TextChannel] = None, + adminrole: Optional[discord.Role] = None, ): if not ctx.guild: return @@ -169,6 +617,12 @@ class ModerationCog(LionCog): instance = setting(ctx.guild.id, modrole.id) modified.append(instance) + if adminrole is not None: + setting = self.settings.AdminRole + await setting._check_value(ctx.guild.id, adminrole) + instance = setting(ctx.guild.id, adminrole.id) + modified.append(instance) + if ticket_log is not None: setting = self.settings.TicketLog await setting._check_value(ctx.guild.id, ticket_log) diff --git a/src/modules/moderation/data.py b/src/modules/moderation/data.py index 77170993..16c4fbdb 100644 --- a/src/modules/moderation/data.py +++ b/src/modules/moderation/data.py @@ -105,6 +105,6 @@ class ModerationData(Registry): file_data = String() expiry = Timestamp() pardoned_by = Integer() - pardoned_at = Integer() + pardoned_at = Timestamp() pardoned_reason = String() created_at = Timestamp() diff --git a/src/modules/moderation/settings.py b/src/modules/moderation/settings.py index a4a8a157..a73416aa 100644 --- a/src/modules/moderation/settings.py +++ b/src/modules/moderation/settings.py @@ -6,6 +6,7 @@ from settings.setting_types import ( from core.data import CoreData from babel.translator import ctx_translator +from wards import low_management_iward, high_management_iward from . import babel @@ -16,6 +17,7 @@ class ModerationSettings(SettingGroup): class TicketLog(ModelData, ChannelSetting): setting_id = "ticket_log" _event = 'guildset_ticket_log' + _write_ward = low_management_iward _display_name = _p('guildset:ticket_log', "ticket_log") _desc = _p( @@ -66,6 +68,7 @@ class ModerationSettings(SettingGroup): class AlertChannel(ModelData, ChannelSetting): setting_id = "alert_channel" _event = 'guildset_alert_channel' + _write_ward = low_management_iward _display_name = _p('guildset:alert_channel', "alert_channel") _desc = _p( @@ -119,18 +122,23 @@ class ModerationSettings(SettingGroup): class ModRole(ModelData, RoleSetting): setting_id = "mod_role" _event = 'guildset_mod_role' + _write_ward = high_management_iward _display_name = _p('guildset:mod_role', "mod_role") _desc = _p( 'guildset:mod_role|desc', - "Guild role permitted to view configuration and perform moderation tasks." + "Server role permitted to perform moderation and minor bot configuration." ) _long_desc = _p( 'guildset:mod_role|long_desc', - "Members with the set role will be able to access my configuration panels, " - "and perform some moderation tasks, such as setting up pomodoro timers. " - "Moderators cannot reconfigure most bot configuration, " - "or perform operations they do not already have permission for in Discord." + "Members with the moderator role are considered moderators," + " and are permitted to use moderator commands," + " such as viewing and pardoning moderation tickets," + " creating moderation notes," + " and performing minor reconfiguration through the `/config` command.\n" + "Moderators are never permitted to perform actions (such as giving roles)" + " that they do not already have the Discord permissions for.\n" + "Members with the 'Manage Guild' permission are always considered moderators." ) _accepts = _p( 'guildset:mod_role|accepts', @@ -148,12 +156,14 @@ class ModerationSettings(SettingGroup): if value: resp = t(_p( 'guildset:mod_role|set_response:set', - "Members with the {role} will be considered moderators." + "Members with {role} will be considered moderators." + " You may need to grant them access to view moderation commands" + " via the server integration settings." )).format(role=value.mention) else: resp = t(_p( 'guildset:mod_role|set_response:unset', - "No members will be given moderation privileges." + "Only members with the 'Manage Guild' permission will be considered moderators." )) return resp @@ -167,3 +177,47 @@ class ModerationSettings(SettingGroup): 'guildset:mod_role|formatted:unset', "Not Set." )) + + class AdminRole(ModelData, RoleSetting): + setting_id = "admin_role" + _event = 'guildset_admin_role' + _write_ward = high_management_iward + + _display_name = _p('guildset:admin_role', "admin_role") + _desc = _p( + 'guildset:admin_role|desc', + "Server role allowing access to all administrator level functionality in Leo." + ) + _long_desc = _p( + 'guildset:admin_role|long_desc', + "Members with this role are considered to be server administrators, " + "allowing them to use all of my interfaces and commands, " + "except for managing roles that are above them in the role hierachy. " + "This setting allows giving members administrator-level permissions " + "over my systems, without actually giving the members admin server permissions. " + "Note that the role will also need to be given permission to see the commands " + "through the Discord server integrations interface." + ) + _accepts = _p( + 'guildset:admin_role|accepts', + "Admin role name or id." + ) + + _model = CoreData.Guild + _column = CoreData.Guild.admin_role.name + + @property + def update_message(self) -> str: + t = ctx_translator.get().t + value = self.value + if value: + resp = t(_p( + 'guildset:admin_role|set_response:set', + "Members with {role} will now be considered admins, and have access to my full interface." + )).format(role=value.mention) + else: + resp = t(_p( + 'guildset:admin_role|set_response:unset', + "The admin role has been unset. Only members with administrator permissions will be considered admins." + )) + return resp diff --git a/src/modules/moderation/settingui.py b/src/modules/moderation/settingui.py index 13a6e456..7d47e79f 100644 --- a/src/modules/moderation/settingui.py +++ b/src/modules/moderation/settingui.py @@ -18,9 +18,10 @@ _p = babel._p class ModerationSettingUI(ConfigUI): setting_classes = ( + ModerationSettings.ModRole, + ModerationSettings.AdminRole, ModerationSettings.TicketLog, ModerationSettings.AlertChannel, - ModerationSettings.ModRole, ) def __init__(self, bot: LionBot, guildid: int, channelid, **kwargs): @@ -41,6 +42,7 @@ class ModerationSettingUI(ConfigUI): await selection.response.defer(thinking=True, ephemeral=True) setting = self.get_instance(ModerationSettings.TicketLog) + await setting.interaction_check(setting.parent_id, selection) setting.value = selected.values[0] if selected.values else None await setting.write() await selection.delete_original_response() @@ -66,6 +68,7 @@ class ModerationSettingUI(ConfigUI): await selection.response.defer(thinking=True, ephemeral=True) setting = self.get_instance(ModerationSettings.AlertChannel) + await setting.interaction_check(setting.parent_id, selection) setting.value = selected.values[0] if selected.values else None await setting.write() await selection.delete_original_response() @@ -91,6 +94,7 @@ class ModerationSettingUI(ConfigUI): await selection.response.defer(thinking=True, ephemeral=True) setting = self.get_instance(ModerationSettings.ModRole) + await setting.interaction_check(setting.parent_id, selection) setting.value = selected.values[0] if selected.values else None await setting.write() await selection.delete_original_response() @@ -103,6 +107,32 @@ class ModerationSettingUI(ConfigUI): "Select Moderator Role" )) + # Admin Role Selector + @select( + cls=RoleSelect, + placeholder="ADMINROLE_MENU_PLACEHOLDER", + min_values=0, max_values=1 + ) + async def adminrole_menu(self, selection: discord.Interaction, selected: RoleSelect): + """ + Single role selector for the `admin_role` setting. + """ + await selection.response.defer(thinking=True, ephemeral=True) + + setting = self.get_instance(ModerationSettings.AdminRole) + await setting.interaction_check(setting.parent_id, selection) + setting.value = selected.values[0] if selected.values else None + await setting.write() + await selection.delete_original_response() + + async def adminrole_menu_refresh(self): + menu = self.adminrole_menu + t = self.bot.translator.t + menu.placeholder = t(_p( + 'ui:moderation_config|menu:adminrole|placeholder', + "Select Admin Role" + )) + # ----- UI Flow ----- async def make_message(self) -> MessageArgs: t = self.bot.translator.t @@ -133,13 +163,15 @@ class ModerationSettingUI(ConfigUI): self.ticket_log_menu_refresh(), self.alert_channel_menu_refresh(), self.modrole_menu_refresh(), + self.adminrole_menu_refresh(), ) await asyncio.gather(*component_refresh) self.set_layout( + (self.adminrole_menu,), + (self.modrole_menu,), (self.ticket_log_menu,), (self.alert_channel_menu,), - (self.modrole_menu,), (self.edit_button, self.reset_button, self.close_button,) ) @@ -147,7 +179,7 @@ class ModerationSettingUI(ConfigUI): class ModerationDashboard(DashboardSection): section_name = _p( "dash:moderation|title", - "Moderation Settings ({commands[configure moderation]})" + "Moderation Settings ({commands[admin config moderation]})" ) _option_name = _p( "dash:moderation|dropdown|placeholder", diff --git a/src/modules/moderation/ticket.py b/src/modules/moderation/ticket.py index b4559d46..bdc98330 100644 --- a/src/modules/moderation/ticket.py +++ b/src/modules/moderation/ticket.py @@ -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) @@ -99,11 +102,11 @@ class Ticket: return tickets @property - def guild(self): + def guild(self) -> Optional[discord.Guild]: return self.bot.get_guild(self.data.guildid) @property - def target(self): + def target(self) -> Optional[discord.Member]: guild = self.guild if guild: return guild.get_member(self.data.targetid) @@ -111,7 +114,7 @@ class Ticket: return None @property - def type(self): + def type(self) -> TicketType: return self.data.ticket_type @property @@ -227,10 +230,10 @@ class Ticket: name=t(_p('ticket|field:pardoned|name', "Pardoned")), value=t(_p( 'ticket|field:pardoned|value', - "Pardoned by <&{moderator}> at {timestamp}.\n{reason}" + "Pardoned by <@{moderator}> at {timestamp}.\n{reason}" )).format( moderator=data.pardoned_by, - timestamp=discord.utils.format_dt(timestamp), + timestamp=discord.utils.format_dt(data.pardoned_at) if data.pardoned_at else 'Unknown', reason=data.pardoned_reason or '' ), inline=False @@ -297,9 +300,6 @@ class Ticket: self.expiring.cancel_tasks(self.data.ticketid) await self.post() - async def _revert(self): - raise NotImplementedError - async def _expire(self): """ Actual expiry method. @@ -321,11 +321,16 @@ class Ticket: await self.post() # TODO: Post an extra note to the modlog about the expiry. - async def revert(self): + async def revert(self, reason: Optional[str] = None, **kwargs): """ Revert this ticket. + + By default this is a no-op. + Ticket types should override to implement any required revert logic. + + The optional `reason` paramter is intended for any auditable actions. """ - raise NotImplementedError + return async def expire(self): """ @@ -336,5 +341,31 @@ class Ticket: """ await self._expire() - async def pardon(self): - raise NotImplementedError + async def pardon(self, modid: int, reason: str): + """ + Pardon a ticket. + + Specifically, set the state of the ticket to `PARDONED`, + with the given moderator and reason, + and revert the ticket if applicable. + + If the ticket is already pardoned, this is a no-op. + """ + if self.data.ticket_state != TicketState.PARDONED: + # Cancel expiry if it was scheduled + self.expiring.cancel_tasks(self.data.ticketid) + + # Revert the ticket if it is currently active + if self.data.ticket_state in (TicketState.OPEN, TicketState.EXPIRING): + await self.revert(reason=f"Pardoned by {modid}") + + # Set pardoned state + await self.data.update( + ticket_state=TicketState.PARDONED, + pardoned_at=utc_now(), + pardoned_by=modid, + pardoned_reason=reason + ) + + # Update ticket log message + await self.post() diff --git a/src/modules/moderation/tickets/__init__.py b/src/modules/moderation/tickets/__init__.py new file mode 100644 index 00000000..a9b39d01 --- /dev/null +++ b/src/modules/moderation/tickets/__init__.py @@ -0,0 +1,2 @@ +from .note import NoteTicket +from .warning import WarnTicket diff --git a/src/modules/moderation/tickets/note.py b/src/modules/moderation/tickets/note.py new file mode 100644 index 00000000..33d92e9c --- /dev/null +++ b/src/modules/moderation/tickets/note.py @@ -0,0 +1,48 @@ +from typing import TYPE_CHECKING +import datetime as dt + +import discord +from meta import LionBot +from utils.lib import utc_now + +from ..ticket import Ticket, ticket_factory +from ..data import TicketType, TicketState, ModerationData +from .. import logger, babel + +if TYPE_CHECKING: + from ..cog import ModerationCog + +_p = babel._p + + +@ticket_factory(TicketType.NOTE) +class NoteTicket(Ticket): + __slots__ = () + + @classmethod + async def create( + cls, bot: LionBot, guildid: int, userid: int, + moderatorid: int, content: str, expiry=None, + **kwargs + ): + modcog: 'ModerationCog' = bot.get_cog('ModerationCog') + ticket_data = await modcog.data.Ticket.create( + guildid=guildid, + targetid=userid, + ticket_type=TicketType.NOTE, + ticket_state=TicketState.OPEN, + moderator_id=moderatorid, + content=content, + expiry=expiry, + created_at=utc_now().replace(tzinfo=None), + **kwargs + ) + + lguild = await bot.core.lions.fetch_guild(guildid) + new_ticket = cls(lguild, ticket_data) + await new_ticket.post() + + if expiry: + cls.expiring.schedule_task(ticket_data.ticketid, expiry.timestamp()) + + return new_ticket diff --git a/src/modules/moderation/tickets/warning.py b/src/modules/moderation/tickets/warning.py new file mode 100644 index 00000000..537ce3f1 --- /dev/null +++ b/src/modules/moderation/tickets/warning.py @@ -0,0 +1,66 @@ +from typing import TYPE_CHECKING, Optional +import datetime as dt + +import discord +from meta import LionBot +from utils.lib import utc_now + +from ..ticket import Ticket, ticket_factory +from ..data import TicketType, TicketState, ModerationData +from .. import logger, babel + +if TYPE_CHECKING: + from ..cog import ModerationCog + +_p = babel._p + + +@ticket_factory(TicketType.WARNING) +class WarnTicket(Ticket): + __slots__ = () + + @classmethod + async def create( + cls, bot: LionBot, guildid: int, userid: int, + moderatorid: int, content: Optional[str], expiry=None, + **kwargs + ): + modcog: 'ModerationCog' = bot.get_cog('ModerationCog') + ticket_data = await modcog.data.Ticket.create( + guildid=guildid, + targetid=userid, + ticket_type=TicketType.WARNING, + ticket_state=TicketState.OPEN, + moderator_id=moderatorid, + content=content, + expiry=expiry, + created_at=utc_now().replace(tzinfo=None), + **kwargs + ) + + lguild = await bot.core.lions.fetch_guild(guildid) + new_ticket = cls(lguild, ticket_data) + await new_ticket.post() + + if expiry: + cls.expiring.schedule_task(ticket_data.ticketid, expiry.timestamp()) + + return new_ticket + + @classmethod + async def count_warnings_for( + cls, bot: LionBot, guildid: int, userid: int, **kwargs + ): + modcog: 'ModerationCog' = bot.get_cog('ModerationCog') + Ticket = modcog.data.Ticket + record = await Ticket.table.select_one_where( + (Ticket.ticket_state != TicketState.PARDONED), + guildid=guildid, + targetid=userid, + ticket_type=TicketType.WARNING, + **kwargs + ).select(ticket_count='COUNT(*)').with_no_adapter() + return (record[0]['ticket_count'] or 0) if record else 0 + + + diff --git a/src/modules/moderation/ticketui.py b/src/modules/moderation/ticketui.py new file mode 100644 index 00000000..28c60f92 --- /dev/null +++ b/src/modules/moderation/ticketui.py @@ -0,0 +1,656 @@ +from itertools import chain +from typing import Optional +from dataclasses import dataclass +import asyncio +import datetime as dt + +import discord +from discord.ui.select import select, Select, SelectOption, UserSelect +from discord.ui.button import button, Button, ButtonStyle +from discord.ui.text_input import TextInput, TextStyle + +from meta import LionBot, conf +from meta.errors import ResponseTimedOut, SafeCancellation, UserInputError +from data import ORDER, Condition + +from utils.ui import MessageUI, input +from utils.lib import MessageArgs, tabulate, utc_now + +from . import babel, logger +from .ticket import Ticket +from .data import ModerationData, TicketType, TicketState + +_p = babel._p + +@dataclass +class TicketFilter: + bot: LionBot + + after: Optional[dt.datetime] = None + before: Optional[dt.datetime] = None + targetids: Optional[list[int]] = None + moderatorids: Optional[list[int]] = None + types: Optional[list[TicketType]] = None + states: Optional[list[TicketState]] = None + + def conditions(self) -> list[Condition]: + conditions = [] + Ticket = ModerationData.Ticket + + if self.after is not None: + conditions.append(Ticket.created_at >= self.after) + if self.before is not None: + conditions.append(Ticket.created_at < self.before) + if self.targetids is not None: + conditions.append(Ticket.targetid == self.targetids) + if self.moderatorids is not None: + conditions.append(Ticket.moderator_id == self.moderatorids) + if self.types is not None: + conditions.append(Ticket.ticket_type == self.types) + if self.states is not None: + conditions.append(Ticket.ticket_state == self.states) + + return conditions + + def formatted(self) -> str: + t = self.bot.translator.t + lines = [] + + if self.after is not None: + name = t(_p( + 'ticketfilter|field:after|name', + "Created After" + )) + value = discord.utils.format_dt(self.after, 'd') + lines.append((name, value)) + + if self.before is not None: + name = t(_p( + 'ticketfilter|field:before|name', + "Created Before" + )) + value = discord.utils.format_dt(self.before, 'd') + lines.append((name, value)) + + if self.targetids is not None: + name = t(_p( + 'ticketfilter|field:targetids|name', + "Targets" + )) + value = ', '.join(f"<@{uid}>" for uid in self.targetids) or 'None' + lines.append((name, value)) + + if self.moderatorids is not None: + name = t(_p( + 'ticketfilter|field:moderatorids|name', + "Moderators" + )) + value = ', '.join(f"<@{uid}>" for uid in self.moderatorids) or 'None' + lines.append((name, value)) + + if self.types is not None: + name = t(_p( + 'ticketfilter|field:types|name', + "Ticket Types" + )) + value = ', '.join(typ.name for typ in self.types) or 'None' + lines.append((name, value)) + + if self.states is not None: + name = t(_p( + 'ticketfilter|field:states|name', + "Ticket States" + )) + value = ', '.join(state.name for state in self.states) or 'None' + lines.append((name, value)) + + if lines: + table = tabulate(*lines) + filterstr = '\n'.join(table) + else: + filterstr = '' + + return filterstr + + +class TicketListUI(MessageUI): + block_len = 10 + + 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, bot: LionBot, guild: discord.Guild, callerid: int, filters=None, **kwargs): + super().__init__(callerid=callerid, **kwargs) + self._children = super()._init_children() + + self.bot = bot + self.data: ModerationData = bot.db.registries[ModerationData.__name__] + self.guild = guild + self.filters = filters or TicketFilter(bot) + + # Paging state + self._pagen = 0 + self.blocks = [[]] + + # UI State + self.show_filters = False + self.show_tickets = False + + self.child_ticket: Optional[TicketUI] = None + + @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] + + # ----- API ----- + + # ----- UI Components ----- + # Edit Filters + @button( + label="EDIT_FILTER_BUTTON_PLACEHOLDER", + style=ButtonStyle.blurple + ) + async def edit_filter_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer(thinking=True, ephemeral=True) + self.show_filters = True + self.show_tickets = False + await self.refresh(thinking=press) + + async def edit_filter_button_refresh(self): + button = self.edit_filter_button + t = self.bot.translator.t + button.label = t(_p( + 'ui:tickets|button:edit_filter|label', + "Edit Filters" + )) + button.style = ButtonStyle.grey if not self.show_filters else ButtonStyle.blurple + + # Select Ticket + @button( + label="SELECT_TICKET_BUTTON_PLACEHOLDER", + style=ButtonStyle.blurple + ) + async def select_ticket_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer(thinking=True, ephemeral=True) + self.show_tickets = True + self.show_filters = False + await self.refresh(thinking=press) + + async def select_ticket_button_refresh(self): + button = self.select_ticket_button + t = self.bot.translator.t + button.label = t(_p( + 'ui:tickets|button:select_ticket|label', + "Select Ticket" + )) + button.style = ButtonStyle.grey if not self.show_tickets else ButtonStyle.blurple + + # Pardon All + @button( + label="PARDON_BUTTON_PLACEHOLDER", + style=ButtonStyle.red + ) + async def pardon_button(self, press: discord.Interaction, pressed: Button): + t = self.bot.translator.t + + tickets = list(chain(*self.blocks)) + if not tickets: + raise UserInputError(t(_p( + 'ui:tickets|button:pardon|error:no_tickets', + "Not tickets matching the given criterial! Nothing to pardon." + ))) + + # Request reason via modal + modal_title = t(_p( + 'ui:tickets|button:pardon|modal:reason|title', + "Pardon Tickets" + )) + input_field = TextInput( + label=t(_p( + 'ui:tickets|button:pardon|modal:reason|field|label', + "Why are you pardoning these tickets?" + )), + style=TextStyle.long, + min_length=0, + max_length=1024, + ) + try: + interaction, reason = await input( + press, modal_title, field=input_field, timeout=300, + ) + except asyncio.TimeoutError: + raise ResponseTimedOut + + await interaction.response.defer(thinking=True, ephemeral=True) + + # Run pardon + for ticket in tickets: + await ticket.pardon(modid=press.user.id, reason=reason) + + await self.refresh(thinking=interaction) + + async def pardon_button_refresh(self): + button = self.pardon_button + t = self.bot.translator.t + button.label = t(_p( + 'ui:tickets|button:pardon|label', + "Pardon All" + )) + button.disabled = not bool(self.current_page) + + # Filter Ticket Type + @select( + cls=Select, + placeholder="FILTER_TYPE_MENU_PLACEHOLDER", + min_values=1, max_values=3, + ) + async def filter_type_menu(self, selection: discord.Interaction, selected: Select): + await selection.response.defer(thinking=True, ephemeral=True) + self.filters.types = [TicketType[value] for value in selected.values] or None + self.pagen = 0 + await self.refresh(thinking=selection) + + async def filter_type_menu_refresh(self): + menu = self.filter_type_menu + t = self.bot.translator.t + menu.placeholder = t(_p( + 'ui:tickets|menu:filter_type|placeholder', + "Select Ticket Types" + )) + + options = [] + descmap = { + TicketType.NOTE: ('Notes',), + TicketType.WARNING: ('Warnings',), + TicketType.STUDY_BAN: ('Video Blacklists',), + } + filtered = self.filters.types + for typ, (name,) in descmap.items(): + option = SelectOption( + label=name, + value=typ.name, + default=(filtered is None or typ in filtered) + ) + options.append(option) + menu.options = options + + # Filter Ticket State + @select( + cls=Select, + placeholder="FILTER_STATE_MENU_PLACEHOLDER", + min_values=1, max_values=4 + ) + async def filter_state_menu(self, selection: discord.Interaction, selected: Select): + await selection.response.defer(thinking=True, ephemeral=True) + self.filters.states = [TicketState[value] for value in selected.values] or None + self.pagen = 0 + await self.refresh(thinking=selection) + + async def filter_state_menu_refresh(self): + menu = self.filter_state_menu + t = self.bot.translator.t + menu.placeholder = t(_p( + 'ui:tickets|menu:filter_state|placeholder', + "Select Ticket States" + )) + + options = [] + descmap = { + TicketState.OPEN: ('OPEN', ), + TicketState.EXPIRING: ('EXPIRING', ), + TicketState.EXPIRED: ('EXPIRED', ), + TicketState.PARDONED: ('PARDONED', ), + } + filtered = self.filters.states + for state, (name,) in descmap.items(): + option = SelectOption( + label=name, + value=state.name, + default=(filtered is None or state in filtered) + ) + options.append(option) + menu.options = options + + # Filter Ticket Target + @select( + cls=UserSelect, + placeholder="FILTER_TARGET_MENU_PLACEHOLDER", + min_values=0, max_values=10 + ) + async def filter_target_menu(self, selection: discord.Interaction, selected: UserSelect): + await selection.response.defer(thinking=True, ephemeral=True) + self.filters.targetids = [user.id for user in selected.values] or None + self.pagen = 0 + await self.refresh(thinking=selection) + + async def filter_target_menu_refresh(self): + menu = self.filter_target_menu + t = self.bot.translator.t + menu.placeholder = t(_p( + 'ui:tickets|menu:filter_target|placeholder', + "Select Ticket Targets" + )) + + # Select Ticket + @select( + cls=Select, + placeholder="TICKETS_MENU_PLACEHOLDER", + min_values=1, max_values=1 + ) + async def tickets_menu(self, selection: discord.Interaction, selected: Select): + await selection.response.defer(thinking=True, ephemeral=True) + if selected.values: + ticketid = int(selected.values[0]) + ticket = await Ticket.fetch_ticket(self.bot, ticketid) + ticketui = TicketUI(self.bot, ticket, self._callerid) + if self.child_ticket: + await self.child_ticket.quit() + self.child_ticket = ticketui + await ticketui.run(selection) + + async def tickets_menu_refresh(self): + menu = self.tickets_menu + t = self.bot.translator.t + menu.placeholder = t(_p( + 'ui:tickets|menu:tickets|placeholder', + "Select Ticket" + )) + options = [] + for ticket in self.current_page: + option = SelectOption( + label=f"Ticket #{ticket.data.guild_ticketid}", + value=str(ticket.data.ticketid) + ) + options.append(option) + menu.options = options + + # 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:tickets|button:jump|input:title', + "Jump to page" + )), + question=t(_p( + 'ui:tickets|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:tickets|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() + if self.child_ticket: + await self.child_ticket.quit() + await self.quit() + + # ----- UI Flow ----- + def _format_ticket(self, ticket) -> str: + """ + Format a ticket into a single embed line. + """ + components = ( + "[#{ticketid}]({link})", + "{created}", + "`{type}[{state}]`", + "<@{targetid}>", + "{content}", + ) + + formatstr = ' | '.join(components) + + data = ticket.data + if not data.content: + content = 'No Content' + elif len(data.content) > 100: + content = data.content[:97] + '...' + else: + content = data.content + + ticketstr = formatstr.format( + ticketid=data.guild_ticketid, + link=ticket.jump_url or 'https://lionbot.org', + created=discord.utils.format_dt(data.created_at, 'd'), + type=data.ticket_type.name, + state=data.ticket_state.name, + targetid=data.targetid, + content=content, + ) + if data.ticket_state is TicketState.PARDONED: + ticketstr = f"~~{ticketstr}~~" + return ticketstr + + async def make_message(self) -> MessageArgs: + t = self.bot.translator.t + embed = discord.Embed( + title=t(_p( + 'ui:tickets|embed|title', + "Moderation Tickets in {guild}" + )).format(guild=self.guild.name), + timestamp=utc_now() + ) + tickets = self.current_page + if tickets: + desc = '\n'.join(self._format_ticket(ticket) for ticket in tickets) + else: + desc = t(_p( + 'ui:tickets|embed|desc:no_tickets', + "No tickets matching the given criteria!" + )) + embed.description = desc + + filterstr = self.filters.formatted() + if filterstr: + embed.add_field( + name=t(_p( + 'ui:tickets|embed|field:filters|name', + "Filters" + )), + value=filterstr, + inline=False + ) + + return MessageArgs(embed=embed) + + async def refresh_layout(self): + to_refresh = ( + self.edit_filter_button_refresh(), + self.select_ticket_button_refresh(), + self.pardon_button_refresh(), + self.tickets_menu_refresh(), + self.filter_type_menu_refresh(), + self.filter_state_menu_refresh(), + self.filter_target_menu_refresh(), + self.jump_button_refresh(), + ) + await asyncio.gather(*to_refresh) + + action_line = ( + self.edit_filter_button, + self.select_ticket_button, + self.pardon_button, + ) + + if self.page_count > 1: + page_line = ( + self.prev_button, + self.jump_button, + self.quit_button, + self.next_button, + ) + else: + page_line = () + action_line = (*action_line, self.quit_button) + + if self.show_filters: + menus = ( + (self.filter_type_menu,), + (self.filter_state_menu,), + (self.filter_target_menu,), + ) + elif self.show_tickets and self.current_page: + menus = ((self.tickets_menu,),) + else: + menus = () + + self.set_layout( + action_line, + *menus, + page_line, + ) + + async def reload(self): + tickets = await Ticket.fetch_tickets( + self.bot, + *self.filters.conditions(), + guildid=self.guild.id, + ) + blocks = [ + tickets[i:i+self.block_len] + for i in range(0, len(tickets), self.block_len) + ] + self.blocks = blocks or [[]] + + +class TicketUI(MessageUI): + def __init__(self, bot: LionBot, ticket: Ticket, callerid: int, **kwargs): + super().__init__(callerid=callerid, **kwargs) + + self.bot = bot + self.ticket = ticket + + # ----- API ----- + + # ----- UI Components ----- + # Pardon Ticket + @button( + label="PARDON_BUTTON_PLACEHOLDER", + style=ButtonStyle.red + ) + async def pardon_button(self, press: discord.Interaction, pressed: Button): + t = self.bot.translator.t + + modal_title = t(_p( + 'ui:ticket|button:pardon|modal:reason|title', + "Pardon Moderation Ticket" + )) + input_field = TextInput( + label=t(_p( + 'ui:ticket|button:pardon|modal:reason|field|label', + "Why are you pardoning this ticket?" + )), + style=TextStyle.long, + min_length=0, + max_length=1024, + ) + try: + interaction, reason = await input( + press, modal_title, field=input_field, timeout=300, + ) + except asyncio.TimeoutError: + raise ResponseTimedOut + + await interaction.response.defer(thinking=True, ephemeral=True) + + await self.ticket.pardon(modid=press.user.id, reason=reason) + await self.refresh(thinking=interaction) + + + async def pardon_button_refresh(self): + button = self.pardon_button + t = self.bot.translator.t + button.label = t(_p( + 'ui:ticket|button:pardon|label', + "Pardon" + )) + button.disabled = (self.ticket.data.ticket_state is TicketState.PARDONED) + + # 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: + return await self.ticket.make_message() + + async def refresh_layout(self): + await self.pardon_button_refresh() + self.set_layout( + (self.pardon_button, self.quit_button,) + ) + + async def reload(self): + await self.ticket.data.refresh() diff --git a/src/modules/pending-rewrite/meta/__init__.py b/src/modules/pending-rewrite/meta/__init__.py deleted file mode 100644 index 3803e00a..00000000 --- a/src/modules/pending-rewrite/meta/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# flake8: noqa -from .module import module - -from . import help -from . import links -from . import nerd -from . import join_message diff --git a/src/modules/pending-rewrite/meta/help.py b/src/modules/pending-rewrite/meta/help.py deleted file mode 100644 index 4835636d..00000000 --- a/src/modules/pending-rewrite/meta/help.py +++ /dev/null @@ -1,237 +0,0 @@ -import discord -from cmdClient.checks import is_owner - -from utils.lib import prop_tabulate -from utils import interactive, ctx_addons # noqa -from wards import is_guild_admin - -from .module import module -from .lib import guide_link - - -new_emoji = " 🆕" -new_commands = {'botconfig', 'sponsors'} - -# Set the command groups to appear in the help -group_hints = { - 'Pomodoro': "*Stay in sync with your friends using our timers!*", - 'Productivity': "*Use these to help you stay focused and productive!*", - 'Statistics': "*StudyLion leaderboards and study statistics.*", - 'Economy': "*Buy, sell, and trade with your hard-earned coins!*", - 'Personal Settings': "*Tell me about yourself!*", - 'Guild Admin': "*Dangerous administration commands!*", - 'Guild Configuration': "*Control how I behave in your server.*", - 'Meta': "*Information about me!*", - 'Support Us': "*Support the team and keep the project alive by using LionGems!*" -} - -standard_group_order = ( - ('Pomodoro', 'Productivity', 'Support Us', 'Statistics', 'Economy', 'Personal Settings', 'Meta'), -) - -mod_group_order = ( - ('Moderation', 'Meta'), - ('Pomodoro', 'Productivity', 'Support Us', 'Statistics', 'Economy', 'Personal Settings') -) - -admin_group_order = ( - ('Guild Admin', 'Guild Configuration', 'Moderation', 'Meta'), - ('Pomodoro', 'Productivity', 'Support Us', 'Statistics', 'Economy', 'Personal Settings') -) - -bot_admin_group_order = ( - ('Bot Admin', 'Guild Admin', 'Guild Configuration', 'Moderation', 'Meta'), - ('Pomodoro', 'Productivity', 'Support Us', 'Statistics', 'Economy', 'Personal Settings') -) - -# Help embed format -# TODO: Add config fields for this -title = "StudyLion Command List" -header = """ -[StudyLion](https://bot.studylions.com/) is a fully featured study assistant \ - that tracks your study time and offers productivity tools \ - such as to-do lists, task reminders, private study rooms, group accountability sessions, and much much more.\n -Use `{{ctx.best_prefix}}help ` (e.g. `{{ctx.best_prefix}}help send`) to learn how to use each command, \ - or [click here]({guide_link}) for a comprehensive tutorial. -""".format(guide_link=guide_link) - - -@module.cmd("help", - group="Meta", - desc="StudyLion command list.", - aliases=('man', 'ls', 'list')) -async def cmd_help(ctx): - """ - Usage``: - {prefix}help [cmdname] - Description: - When used with no arguments, displays a list of commands with brief descriptions. - Otherwise, shows documentation for the provided command. - Examples: - {prefix}help - {prefix}help top - {prefix}help timezone - """ - if ctx.arg_str: - # Attempt to fetch the command - command = ctx.client.cmd_names.get(ctx.arg_str.strip(), None) - if command is None: - return await ctx.error_reply( - ("Command `{}` not found!\n" - "Write `{}help` to see a list of commands.").format(ctx.args, ctx.best_prefix) - ) - - smart_help = getattr(command, 'smart_help', None) - if smart_help is not None: - return await smart_help(ctx) - - help_fields = command.long_help.copy() - help_map = {field_name: i for i, (field_name, _) in enumerate(help_fields)} - - if not help_map: - return await ctx.reply("No documentation has been written for this command yet!") - - field_pages = [[]] - page_fields = field_pages[0] - for name, pos in help_map.items(): - if name.endswith("``"): - # Handle codeline help fields - page_fields.append(( - name.strip("`"), - "`{}`".format('`\n`'.join(help_fields[pos][1].splitlines())) - )) - elif name.endswith(":"): - # Handle property/value help fields - lines = help_fields[pos][1].splitlines() - - names = [] - values = [] - for line in lines: - split = line.split(":", 1) - names.append(split[0] if len(split) > 1 else "") - values.append(split[-1]) - - page_fields.append(( - name.strip(':'), - prop_tabulate(names, values) - )) - elif name == "Related": - # Handle the related field - names = [cmd_name.strip() for cmd_name in help_fields[pos][1].split(',')] - names.sort(key=len) - values = [ - (getattr(ctx.client.cmd_names.get(cmd_name, None), 'desc', '') or '').format(ctx=ctx) - for cmd_name in names - ] - page_fields.append(( - name, - prop_tabulate(names, values) - )) - elif name == "PAGEBREAK": - page_fields = [] - field_pages.append(page_fields) - else: - page_fields.append((name, help_fields[pos][1])) - - # Build the aliases - aliases = getattr(command, 'aliases', []) - alias_str = "(Aliases `{}`.)".format("`, `".join(aliases)) if aliases else "" - - # Build the embeds - pages = [] - for i, page_fields in enumerate(field_pages): - embed = discord.Embed( - title="`{}` command documentation. {}".format( - command.name, - alias_str - ), - colour=discord.Colour(0x9b59b6) - ) - for fieldname, fieldvalue in page_fields: - embed.add_field( - name=fieldname, - value=fieldvalue.format(ctx=ctx, prefix=ctx.best_prefix), - inline=False - ) - - embed.set_footer( - text="{}\n[optional] and denote optional and required arguments, respectively.".format( - "Page {} of {}".format(i + 1, len(field_pages)) if len(field_pages) > 1 else '', - ) - ) - pages.append(embed) - - # Post the embed - await ctx.pager(pages) - else: - # Build the command groups - cmd_groups = {} - for command in ctx.client.cmds: - # Get the command group - group = getattr(command, 'group', "Misc") - cmd_group = cmd_groups.get(group, []) - if not cmd_group: - cmd_groups[group] = cmd_group - - # Add the command name and description to the group - cmd_group.append( - (command.name, (getattr(command, 'desc', '') + (new_emoji if command.name in new_commands else ''))) - ) - - # Add any required aliases - for alias, desc in getattr(command, 'help_aliases', {}).items(): - cmd_group.append((alias, desc)) - - # Turn the command groups into strings - stringy_cmd_groups = {} - for group_name, cmd_group in cmd_groups.items(): - cmd_group.sort(key=lambda tup: len(tup[0])) - if ctx.alias == 'ls': - stringy_cmd_groups[group_name] = ', '.join( - f"`{name}`" for name, _ in cmd_group - ) - else: - stringy_cmd_groups[group_name] = prop_tabulate(*zip(*cmd_group)) - - # Now put everything into a bunch of embeds - if await is_owner.run(ctx): - group_order = bot_admin_group_order - elif ctx.guild: - if is_guild_admin(ctx.author): - group_order = admin_group_order - elif ctx.guild_settings.mod_role.value in ctx.author.roles: - group_order = mod_group_order - else: - group_order = standard_group_order - else: - group_order = admin_group_order - - help_embeds = [] - for page_groups in group_order: - embed = discord.Embed( - description=header.format(ctx=ctx), - colour=discord.Colour(0x9b59b6), - title=title - ) - for group in page_groups: - group_hint = group_hints.get(group, '').format(ctx=ctx) - group_str = stringy_cmd_groups.get(group, None) - if group_str: - embed.add_field( - name=group, - value="{}\n{}".format(group_hint, group_str).format(ctx=ctx), - inline=False - ) - help_embeds.append(embed) - - # Add the page numbers - for i, embed in enumerate(help_embeds): - embed.set_footer(text="Page {}/{}".format(i+1, len(help_embeds))) - - # Send the embeds - if help_embeds: - await ctx.pager(help_embeds) - else: - await ctx.reply( - embed=discord.Embed(description=header, colour=discord.Colour(0x9b59b6)) - ) diff --git a/src/modules/pending-rewrite/meta/join_message.py b/src/modules/pending-rewrite/meta/join_message.py deleted file mode 100644 index 4abd1b1d..00000000 --- a/src/modules/pending-rewrite/meta/join_message.py +++ /dev/null @@ -1,50 +0,0 @@ -import discord - -from cmdClient import cmdClient - -from meta import client, conf -from .lib import guide_link, animation_link - - -message = """ -Thank you for inviting me to your community. -Get started by typing `{prefix}help` to see my commands, and `{prefix}config info` \ - to read about my configuration options! - -To learn how to configure me and use all of my features, \ - make sure to [click here]({guide_link}) to read our full setup guide. - -Remember, if you need any help configuring me, \ - want to suggest a feature, report a bug and stay updated, \ - make sure to join our main support and study server by [clicking here]({support_link}). - -Best of luck with your studies! - -""".format( - guide_link=guide_link, - support_link=conf.bot.get('support_link'), - prefix=client.prefix -) - - -@client.add_after_event('guild_join', priority=0) -async def post_join_message(client: cmdClient, guild: discord.Guild): - try: - await guild.me.edit(nick="Leo") - except discord.HTTPException: - pass - if (channel := guild.system_channel) and channel.permissions_for(guild.me).embed_links: - embed = discord.Embed( - description=message - ) - embed.set_author( - name="Hello everyone! My name is Leo, the StudyLion!", - icon_url="https://cdn.discordapp.com/emojis/933610591459872868.webp" - ) - embed.set_image(url=animation_link) - try: - await channel.send(embed=embed) - except discord.HTTPException: - # Something went wrong sending the hi message - # Not much we can do about this - pass diff --git a/src/modules/pending-rewrite/meta/lib.py b/src/modules/pending-rewrite/meta/lib.py deleted file mode 100644 index 22b42474..00000000 --- a/src/modules/pending-rewrite/meta/lib.py +++ /dev/null @@ -1,5 +0,0 @@ -guide_link = "https://discord.studylions.com/tutorial" - -animation_link = ( - "https://media.discordapp.net/attachments/879412267731542047/926837189814419486/ezgif.com-resize.gif" -) diff --git a/src/modules/pending-rewrite/meta/links.py b/src/modules/pending-rewrite/meta/links.py deleted file mode 100644 index 476caf26..00000000 --- a/src/modules/pending-rewrite/meta/links.py +++ /dev/null @@ -1,57 +0,0 @@ -import discord - -from meta import conf - -from LionContext import LionContext as Context - -from .module import module -from .lib import guide_link - - -@module.cmd( - "support", - group="Meta", - desc=f"Have a question? Join my [support server]({conf.bot.get('support_link')})" -) -async def cmd_support(ctx: Context): - """ - Usage``: - {prefix}support - Description: - Replies with an invite link to my support server. - """ - await ctx.reply( - f"Click here to join my support server: {conf.bot.get('support_link')}" - ) - - -@module.cmd( - "invite", - group="Meta", - desc=f"[Invite me]({conf.bot.get('invite_link')}) to your server so I can help your members stay productive!" -) -async def cmd_invite(ctx: Context): - """ - Usage``: - {prefix}invite - Description: - Replies with my invite link so you can add me to your server. - """ - embed = discord.Embed( - colour=discord.Colour.orange(), - description=f"Click here to add me to your server: {conf.bot.get('invite_link')}" - ) - embed.add_field( - name="Setup tips", - value=( - "Remember to check out `{prefix}help` for the full command list, " - "and `{prefix}config info` for the configuration options.\n" - "[Click here]({guide}) for our comprehensive setup tutorial, and if you still have questions you can " - "join our support server [here]({support}) to talk to our friendly support team!" - ).format( - prefix=ctx.best_prefix, - support=conf.bot.get('support_link'), - guide=guide_link - ) - ) - await ctx.reply(embed=embed) diff --git a/src/modules/pending-rewrite/meta/module.py b/src/modules/pending-rewrite/meta/module.py deleted file mode 100644 index 1e030669..00000000 --- a/src/modules/pending-rewrite/meta/module.py +++ /dev/null @@ -1,3 +0,0 @@ -from LionModule import LionModule - -module = LionModule("Meta") diff --git a/src/modules/pending-rewrite/meta/nerd.py b/src/modules/pending-rewrite/meta/nerd.py deleted file mode 100644 index 8eb0930d..00000000 --- a/src/modules/pending-rewrite/meta/nerd.py +++ /dev/null @@ -1,144 +0,0 @@ -import datetime -import asyncio -import discord -import psutil -import sys -import gc - -from data import NOTNULL -from data.queries import select_where -from utils.lib import prop_tabulate, utc_now - -from LionContext import LionContext as Context - -from .module import module - - -process = psutil.Process() -process.cpu_percent() - - -@module.cmd( - "nerd", - group="Meta", - desc="Information and statistics about me!" -) -async def cmd_nerd(ctx: Context): - """ - Usage``: - {prefix}nerd - Description: - View nerdy information and statistics about me! - """ - # Create embed - embed = discord.Embed( - colour=discord.Colour.orange(), - title="Nerd Panel", - description=( - "Hi! I'm [StudyLion]({studylion}), a study management bot owned by " - "[Ari Horesh]({ari}) and developed by [Conatum#5317]({cona}), with [contributors]({github})." - ).format( - studylion="http://studylions.com/", - ari="https://arihoresh.com/", - cona="https://github.com/Intery", - github="https://github.com/StudyLions/StudyLion" - ) - ) - - # ----- Study stats ----- - # Current studying statistics - current_students, current_channels, current_guilds= ( - ctx.client.data.current_sessions.select_one_where( - select_columns=( - "COUNT(*) AS studying_count", - "COUNT(DISTINCT(channelid)) AS channel_count", - "COUNT(DISTINCT(guildid)) AS guild_count" - ) - ) - ) - - # Past studying statistics - past_sessions, past_students, past_duration, past_guilds = ctx.client.data.session_history.select_one_where( - select_columns=( - "COUNT(*) AS session_count", - "COUNT(DISTINCT(userid)) AS user_count", - "SUM(duration) / 3600 AS total_hours", - "COUNT(DISTINCT(guildid)) AS guild_count" - ) - ) - - # Tasklist statistics - tasks = ctx.client.data.tasklist.select_one_where( - select_columns=( - 'COUNT(*)' - ) - )[0] - - tasks_completed = ctx.client.data.tasklist.select_one_where( - completed_at=NOTNULL, - select_columns=( - 'COUNT(*)' - ) - )[0] - - # Timers - timer_count, timer_guilds = ctx.client.data.timers.select_one_where( - select_columns=("COUNT(*)", "COUNT(DISTINCT(guildid))") - ) - - study_fields = { - "Currently": f"`{current_students}` people working in `{current_channels}` rooms of `{current_guilds}` guilds", - "Recorded": f"`{past_duration}` hours from `{past_students}` people across `{past_sessions}` sessions", - "Tasks": f"`{tasks_completed}` out of `{tasks}` tasks completed", - "Timers": f"`{timer_count}` timers running in `{timer_guilds}` communities" - } - study_table = prop_tabulate(*zip(*study_fields.items())) - - # ----- Shard statistics ----- - shard_number = ctx.client.shard_id - shard_count = ctx.client.shard_count - guilds = len(ctx.client.guilds) - member_count = sum(guild.member_count for guild in ctx.client.guilds) - commands = len(ctx.client.cmds) - aliases = len(ctx.client.cmd_names) - dpy_version = discord.__version__ - py_version = sys.version.split()[0] - data_version, data_time, _ = select_where( - "VersionHistory", - _extra="ORDER BY time DESC LIMIT 1" - )[0] - data_timestamp = int(data_time.replace(tzinfo=datetime.timezone.utc).timestamp()) - - shard_fields = { - "Shard": f"`{shard_number}` of `{shard_count}`", - "Guilds": f"`{guilds}` servers with `{member_count}` members (on this shard)", - "Commands": f"`{commands}` commands with `{aliases}` keywords", - "Version": f"`v{data_version}`, last updated ", - "Py version": f"`{py_version}` running discord.py `{dpy_version}`" - } - shard_table = prop_tabulate(*zip(*shard_fields.items())) - - - # ----- Execution statistics ----- - running_commands = len(ctx.client.active_contexts) - tasks = len(asyncio.all_tasks()) - objects = len(gc.get_objects()) - cpu_percent = process.cpu_percent() - mem_percent = int(process.memory_percent()) - uptime = int(utc_now().timestamp() - process.create_time()) - - execution_fields = { - "Running": f"`{running_commands}` commands", - "Waiting for": f"`{tasks}` tasks to complete", - "Objects": f"`{objects}` loaded in memory", - "Usage": f"`{cpu_percent}%` CPU, `{mem_percent}%` MEM", - "Uptime": f"`{uptime // (24 * 3600)}` days, `{uptime // 3600 % 24:02}:{uptime // 60 % 60:02}:{uptime % 60:02}`" - } - execution_table = prop_tabulate(*zip(*execution_fields.items())) - - # ----- Combine and output ----- - embed.add_field(name="Study Stats", value=study_table, inline=False) - embed.add_field(name=f"Shard Info", value=shard_table, inline=False) - embed.add_field(name=f"Process Stats", value=execution_table, inline=False) - - await ctx.reply(embed=embed) diff --git a/src/modules/pending-rewrite/moderation/__init__.py b/src/modules/pending-rewrite/moderation/__init__.py deleted file mode 100644 index e1cc7d79..00000000 --- a/src/modules/pending-rewrite/moderation/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from .module import module - -from . import data -from . import admin - -from . import tickets -from . import video - -from . import commands diff --git a/src/modules/pending-rewrite/moderation/admin.py b/src/modules/pending-rewrite/moderation/admin.py deleted file mode 100644 index 73402a35..00000000 --- a/src/modules/pending-rewrite/moderation/admin.py +++ /dev/null @@ -1,109 +0,0 @@ -import discord - -from settings import GuildSettings, GuildSetting -from wards import guild_admin - -import settings - -from .data import studyban_durations - - -@GuildSettings.attach_setting -class mod_log(settings.Channel, GuildSetting): - category = "Moderation" - - attr_name = 'mod_log' - _data_column = 'mod_log_channel' - - display_name = "mod_log" - desc = "Moderation event logging channel." - - long_desc = ( - "Channel to post moderation tickets.\n" - "These are produced when a manual or automatic moderation action is performed on a member. " - "This channel acts as a more context rich moderation history source than the audit log." - ) - - _chan_type = discord.ChannelType.text - - @property - def success_response(self): - if self.value: - return "Moderation tickets will be posted to {}.".format(self.formatted) - else: - return "The moderation log has been unset." - - -@GuildSettings.attach_setting -class studyban_role(settings.Role, GuildSetting): - category = "Moderation" - - attr_name = 'studyban_role' - _data_column = 'studyban_role' - - display_name = "studyban_role" - desc = "The role given to members to prevent them from using server study features." - - long_desc = ( - "This role is to be given to members to prevent them from using the server's study features.\n" - "Typically, this role should act as a 'partial mute', and prevent the user from joining study voice channels, " - "or participating in study text channels.\n" - "It will be given automatically after study related offences, " - "such as not enabling video in the video-only channels." - ) - - @property - def success_response(self): - if self.value: - return "The study ban role is now {}.".format(self.formatted) - - -@GuildSettings.attach_setting -class studyban_durations(settings.SettingList, settings.ListData, settings.Setting): - category = "Moderation" - - attr_name = 'studyban_durations' - - _table_interface = studyban_durations - _id_column = 'guildid' - _data_column = 'duration' - _order_column = "rowid" - - _default = [ - 5 * 60, - 60 * 60, - 6 * 60 * 60, - 24 * 60 * 60, - 168 * 60 * 60, - 720 * 60 * 60 - ] - - _setting = settings.Duration - - write_ward = guild_admin - display_name = "studyban_durations" - desc = "Sequence of durations for automatic study bans." - - long_desc = ( - "This sequence describes how long a member will be automatically study-banned for " - "after committing a study-related offence (such as not enabling their video in video only channels).\n" - "If the sequence is `1d, 7d, 30d`, for example, the member will be study-banned " - "for `1d` on their first offence, `7d` on their second offence, and `30d` on their third. " - "On their fourth offence, they will not be unbanned.\n" - "This does not count pardoned offences." - ) - accepts = ( - "Comma separated list of durations in days/hours/minutes/seconds, for example `12h, 1d, 7d, 30d`." - ) - - # Flat cache, no need to expire objects - _cache = {} - - @property - def success_response(self): - if self.value: - return "The automatic study ban durations are now {}.".format(self.formatted) - else: - return "Automatic study bans will never be reverted." - - diff --git a/src/modules/pending-rewrite/moderation/commands.py b/src/modules/pending-rewrite/moderation/commands.py deleted file mode 100644 index a6dc150f..00000000 --- a/src/modules/pending-rewrite/moderation/commands.py +++ /dev/null @@ -1,448 +0,0 @@ -""" -Shared commands for the moderation module. -""" -import asyncio -from collections import defaultdict -import discord - -from cmdClient.lib import ResponseTimedOut -from wards import guild_moderator - -from .module import module -from .tickets import Ticket, TicketType, TicketState - - -type_accepts = { - 'note': TicketType.NOTE, - 'notes': TicketType.NOTE, - 'studyban': TicketType.STUDY_BAN, - 'studybans': TicketType.STUDY_BAN, - 'warn': TicketType.WARNING, - 'warns': TicketType.WARNING, - 'warning': TicketType.WARNING, - 'warnings': TicketType.WARNING, -} - -type_formatted = { - TicketType.NOTE: 'NOTE', - TicketType.STUDY_BAN: 'STUDYBAN', - TicketType.WARNING: 'WARNING', -} - -type_summary_formatted = { - TicketType.NOTE: 'note', - TicketType.STUDY_BAN: 'studyban', - TicketType.WARNING: 'warning', -} - -state_formatted = { - TicketState.OPEN: 'ACTIVE', - TicketState.EXPIRING: 'TEMP', - TicketState.EXPIRED: 'EXPIRED', - TicketState.PARDONED: 'PARDONED' -} - -state_summary_formatted = { - TicketState.OPEN: 'Active', - TicketState.EXPIRING: 'Temporary', - TicketState.EXPIRED: 'Expired', - TicketState.REVERTED: 'Manually Reverted', - TicketState.PARDONED: 'Pardoned' -} - - -@module.cmd( - "tickets", - group="Moderation", - desc="View and filter the server moderation tickets.", - flags=('active', 'type=') -) -@guild_moderator() -async def cmd_tickets(ctx, flags): - """ - Usage``: - {prefix}tickets [@user] [--type ] [--active] - Description: - Display and optionally filter the moderation event history in this guild. - Flags:: - type: Filter by ticket type. See **Ticket Types** below. - active: Only show in-effect tickets (i.e. hide expired and pardoned ones). - Ticket Types:: - note: Moderation notes. - warn: Moderation warnings, both manual and automatic. - studyban: Bans from using study features from abusing the study system. - blacklist: Complete blacklisting from using my commands. - Ticket States:: - Active: Active tickets that will not automatically expire. - Temporary: Active tickets that will automatically expire after a set duration. - Expired: Tickets that have automatically expired. - Reverted: Tickets with actions that have been reverted. - Pardoned: Tickets that have been pardoned and no longer apply to the user. - Examples: - {prefix}tickets {ctx.guild.owner.mention} --type warn --active - """ - # Parse filter fields - # First the user - if ctx.args: - userstr = ctx.args.strip('<@!&> ') - if not userstr.isdigit(): - return await ctx.error_reply( - "**Usage:** `{prefix}tickets [@user] [--type ] [--active]`.\n" - "Please provide the `user` as a mention or id!".format(prefix=ctx.best_prefix) - ) - filter_userid = int(userstr) - else: - filter_userid = None - - if flags['type']: - typestr = flags['type'].lower() - if typestr not in type_accepts: - return await ctx.error_reply( - "Please see `{prefix}help tickets` for the valid ticket types!".format(prefix=ctx.best_prefix) - ) - filter_type = type_accepts[typestr] - else: - filter_type = None - - filter_active = flags['active'] - - # Build the filter arguments - filters = {'guildid': ctx.guild.id} - if filter_userid: - filters['targetid'] = filter_userid - if filter_type: - filters['ticket_type'] = filter_type - if filter_active: - filters['ticket_state'] = [TicketState.OPEN, TicketState.EXPIRING] - - # Fetch the tickets with these filters - tickets = Ticket.fetch_tickets(**filters) - - if not tickets: - if filters: - return await ctx.embed_reply("There are no tickets with these criteria!") - else: - return await ctx.embed_reply("There are no moderation tickets in this server!") - - tickets = sorted(tickets, key=lambda ticket: ticket.data.guild_ticketid, reverse=True) - ticket_map = {ticket.data.guild_ticketid: ticket for ticket in tickets} - - # Build the format string based on the filters - components = [] - # Ticket id with link to message in mod log - components.append("[#{ticket.data.guild_ticketid}]({ticket.link})") - # Ticket creation date - components.append("") - # Ticket type, with current state - if filter_type is None: - if not filter_active: - components.append("`{ticket_type}{ticket_state}`") - else: - components.append("`{ticket_type}`") - elif not filter_active: - components.append("`{ticket_real_state}`") - if not filter_userid: - # Ticket user - components.append("<@{ticket.data.targetid}>") - if filter_userid or (filter_active and filter_type): - # Truncated ticket content - components.append("{content}") - - format_str = ' | '.join(components) - - # Break tickets into blocks - blocks = [tickets[i:i+10] for i in range(0, len(tickets), 10)] - - # Build pages of tickets - ticket_pages = [] - for block in blocks: - ticket_page = [] - - type_len = max(len(type_formatted[ticket.type]) for ticket in block) - state_len = max(len(state_formatted[ticket.state]) for ticket in block) - for ticket in block: - # First truncate content if required - content = ticket.data.content - if len(content) > 40: - content = content[:37] + '...' - - # Build ticket line - line = format_str.format( - ticket=ticket, - timestamp=ticket.data.created_at.timestamp(), - ticket_type=type_formatted[ticket.type], - type_len=type_len, - ticket_state=" [{}]".format(state_formatted[ticket.state]) if ticket.state != TicketState.OPEN else '', - ticket_real_state=state_formatted[ticket.state], - state_len=state_len, - content=content - ) - if ticket.state == TicketState.PARDONED: - line = "~~{}~~".format(line) - - # Add to current page - ticket_page.append(line) - # Combine lines and add page to pages - ticket_pages.append('\n'.join(ticket_page)) - - # Build active ticket type summary - freq = defaultdict(int) - for ticket in tickets: - if ticket.state != TicketState.PARDONED: - freq[ticket.type] += 1 - summary_pairs = [ - (num, type_summary_formatted[ttype] + ('s' if num > 1 else '')) - for ttype, num in freq.items() - ] - summary_pairs.sort(key=lambda pair: pair[0]) - # num_len = max(len(str(num)) for num in freq.values()) - # type_summary = '\n'.join( - # "**`{:<{}}`** {}".format(pair[0], num_len, pair[1]) - # for pair in summary_pairs - # ) - - # # Build status summary - # freq = defaultdict(int) - # for ticket in tickets: - # freq[ticket.state] += 1 - # num_len = max(len(str(num)) for num in freq.values()) - # status_summary = '\n'.join( - # "**`{:<{}}`** {}".format(freq[state], num_len, state_str) - # for state, state_str in state_summary_formatted.items() - # if state in freq - # ) - - summary_strings = [ - "**`{}`** {}".format(*pair) for pair in summary_pairs - ] - if len(summary_strings) > 2: - summary = ', '.join(summary_strings[:-1]) + ', and ' + summary_strings[-1] - elif len(summary_strings) == 2: - summary = ' and '.join(summary_strings) - else: - summary = ''.join(summary_strings) - if summary: - summary += '.' - - # Build embed info - title = "{}{}{}".format( - "Active " if filter_active else '', - "{} tickets ".format(type_formatted[filter_type]) if filter_type else "Tickets ", - (" for {}".format(ctx.guild.get_member(filter_userid) or filter_userid) - if filter_userid else " in {}".format(ctx.guild.name)) - ) - footer = "Click a ticket id to jump to it, or type the number to show the full ticket." - page_count = len(blocks) - if page_count > 1: - footer += "\nPage {{page_num}}/{}".format(page_count) - - # Create embeds - embeds = [ - discord.Embed( - title=title, - description="{}\n{}".format(summary, page), - colour=discord.Colour.orange(), - ).set_footer(text=footer.format(page_num=i+1)) - for i, page in enumerate(ticket_pages) - ] - - # Run output with cancellation and listener - out_msg = await ctx.pager(embeds, add_cancel=True) - old_task = _displays.pop((ctx.ch.id, ctx.author.id), None) - if old_task: - old_task.cancel() - _displays[(ctx.ch.id, ctx.author.id)] = display_task = asyncio.create_task(_ticket_display(ctx, ticket_map)) - ctx.tasks.append(display_task) - await ctx.cancellable(out_msg, add_reaction=False) - - -_displays = {} # (channelid, userid) -> Task -async def _ticket_display(ctx, ticket_map): - """ - Display tickets when the ticket number is entered. - """ - current_ticket_msg = None - - try: - while True: - # Wait for a number - try: - result = await ctx.client.wait_for( - "message", - check=lambda msg: (msg.author == ctx.author - and msg.channel == ctx.ch - and msg.content.isdigit() - and int(msg.content) in ticket_map), - timeout=60 - ) - except asyncio.TimeoutError: - return - - # Delete the response - try: - await result.delete() - except discord.HTTPException: - pass - - # Display the ticket - embed = ticket_map[int(result.content)].msg_args['embed'] - if current_ticket_msg: - try: - await current_ticket_msg.edit(embed=embed) - except discord.HTTPException: - current_ticket_msg = None - - if not current_ticket_msg: - try: - current_ticket_msg = await ctx.reply(embed=embed) - except discord.HTTPException: - return - asyncio.create_task(ctx.offer_delete(current_ticket_msg)) - except asyncio.CancelledError: - if current_ticket_msg: - try: - await current_ticket_msg.delete() - except discord.HTTPException: - pass - - - -@module.cmd( - "pardon", - group="Moderation", - desc="Pardon a ticket, or clear a member's moderation history.", - flags=('type=',) -) -@guild_moderator() -async def cmd_pardon(ctx, flags): - """ - Usage``: - {prefix}pardon ticketid, ticketid, ticketid - {prefix}pardon @user [--type ] - Description: - Marks the given tickets as no longer applicable. - These tickets will not be considered when calculating automod actions such as automatic study bans. - - This may be used to mark warns or other tickets as no longer in-effect. - If the ticket is active when it is pardoned, it will be reverted, and any expiry cancelled. - - Use the `{prefix}tickets` command to view the relevant tickets. - Flags:: - type: Filter by ticket type. See **Ticket Types** in `{prefix}help tickets`. - Examples: - {prefix}pardon 21 - {prefix}pardon {ctx.guild.owner.mention} --type warn - """ - usage = "**Usage**: `{prefix}pardon ticketid` or `{prefix}pardon @user`.".format(prefix=ctx.best_prefix) - if not ctx.args: - return await ctx.error_reply( - usage - ) - - # Parse provided tickets or filters - targetid = None - ticketids = [] - args = {'guildid': ctx.guild.id} - if ',' in ctx.args: - # Assume provided numbers are ticketids. - items = [item.strip() for item in ctx.args.split(',')] - if not all(item.isdigit() for item in items): - return await ctx.error_reply(usage) - ticketids = [int(item) for item in items] - args['guild_ticketid'] = ticketids - else: - # Guess whether the provided numbers were ticketids or not - idstr = ctx.args.strip('<@!&> ') - if not idstr.isdigit(): - return await ctx.error_reply(usage) - - maybe_id = int(idstr) - if maybe_id > 4194304: # Testing whether it is greater than the minimum snowflake id - # Assume userid - targetid = maybe_id - args['targetid'] = maybe_id - - # Add the type filter if provided - if flags['type']: - typestr = flags['type'].lower() - if typestr not in type_accepts: - return await ctx.error_reply( - "Please see `{prefix}help tickets` for the valid ticket types!".format(prefix=ctx.best_prefix) - ) - args['ticket_type'] = type_accepts[typestr] - else: - # Assume guild ticketid - ticketids = [maybe_id] - args['guild_ticketid'] = maybe_id - - # Fetch the matching tickets - tickets = Ticket.fetch_tickets(**args) - - # Check whether we have the right selection of tickets - if targetid and not tickets: - return await ctx.error_reply( - "<@{}> has no matching tickets to pardon!" - ) - if ticketids and len(ticketids) != len(tickets): - # Not all of the ticketids were valid - difference = list(set(ticketids).difference(ticket.ticketid for ticket in tickets)) - if len(difference) == 1: - return await ctx.error_reply( - "Couldn't find ticket `{}`!".format(difference[0]) - ) - else: - return await ctx.error_reply( - "Couldn't find any of the following tickets:\n`{}`".format( - '`, `'.join(difference) - ) - ) - - # Check whether there are any tickets left to pardon - to_pardon = [ticket for ticket in tickets if ticket.state != TicketState.PARDONED] - if not to_pardon: - if ticketids and len(tickets) == 1: - ticket = tickets[0] - return await ctx.error_reply( - "[Ticket #{}]({}) is already pardoned!".format(ticket.data.guild_ticketid, ticket.link) - ) - else: - return await ctx.error_reply( - "All of these tickets are already pardoned!" - ) - - # We now know what tickets we want to pardon - # Request the pardon reason - try: - reason = await ctx.input("Please provide a reason for the pardon.") - except ResponseTimedOut: - raise ResponseTimedOut("Prompt timed out, no tickets were pardoned.") - - # Pardon the tickets - for ticket in to_pardon: - await ticket.pardon(ctx.author, reason) - - # Finally, ack the pardon - if targetid: - await ctx.embed_reply( - "The active {}s for <@{}> have been cleared.".format( - type_summary_formatted[args['ticket_type']] if flags['type'] else 'ticket', - targetid - ) - ) - elif len(to_pardon) == 1: - ticket = to_pardon[0] - await ctx.embed_reply( - "[Ticket #{}]({}) was pardoned.".format( - ticket.data.guild_ticketid, - ticket.link - ) - ) - else: - await ctx.embed_reply( - "The following tickets were pardoned.\n{}".format( - ", ".join( - "[#{}]({})".format(ticket.data.guild_ticketid, ticket.link) - for ticket in to_pardon - ) - ) - ) diff --git a/src/modules/pending-rewrite/moderation/data.py b/src/modules/pending-rewrite/moderation/data.py deleted file mode 100644 index e7f00594..00000000 --- a/src/modules/pending-rewrite/moderation/data.py +++ /dev/null @@ -1,19 +0,0 @@ -from data import Table, RowTable - - -studyban_durations = Table('studyban_durations') - -ticket_info = RowTable( - 'ticket_info', - ('ticketid', 'guild_ticketid', - 'guildid', 'targetid', 'ticket_type', 'ticket_state', 'moderator_id', 'auto', - 'log_msg_id', 'created_at', - 'content', 'context', 'addendum', 'duration', - 'file_name', 'file_data', - 'expiry', - 'pardoned_by', 'pardoned_at', 'pardoned_reason'), - 'ticketid', - cache_size=20000 -) - -tickets = Table('tickets') diff --git a/src/modules/pending-rewrite/moderation/module.py b/src/modules/pending-rewrite/moderation/module.py deleted file mode 100644 index bc286ace..00000000 --- a/src/modules/pending-rewrite/moderation/module.py +++ /dev/null @@ -1,4 +0,0 @@ -from cmdClient import Module - - -module = Module("Moderation") diff --git a/src/modules/pending-rewrite/moderation/tickets/Ticket.py b/src/modules/pending-rewrite/moderation/tickets/Ticket.py deleted file mode 100644 index afea1eef..00000000 --- a/src/modules/pending-rewrite/moderation/tickets/Ticket.py +++ /dev/null @@ -1,486 +0,0 @@ -import asyncio -import logging -import traceback -import datetime - -import discord - -from meta import client -from data.conditions import THIS_SHARD -from settings import GuildSettings -from utils.lib import FieldEnum, strfdelta, utc_now - -from .. import data -from ..module import module - - -class TicketType(FieldEnum): - """ - The possible ticket types. - """ - NOTE = 'NOTE', 'Note' - WARNING = 'WARNING', 'Warning' - STUDY_BAN = 'STUDY_BAN', 'Study Ban' - MESAGE_CENSOR = 'MESSAGE_CENSOR', 'Message Censor' - INVITE_CENSOR = 'INVITE_CENSOR', 'Invite Censor' - - -class TicketState(FieldEnum): - """ - The possible ticket states. - """ - OPEN = 'OPEN', "Active" - EXPIRING = 'EXPIRING', "Active" - EXPIRED = 'EXPIRED', "Expired" - PARDONED = 'PARDONED', "Pardoned" - REVERTED = 'REVERTED', "Reverted" - - -class Ticket: - """ - Abstract base class representing a Ticketed moderation action. - """ - # Type of event the class represents - _ticket_type = None # type: TicketType - - _ticket_types = {} # Map: TicketType -> Ticket subclass - - _expiry_tasks = {} # Map: ticketid -> expiry Task - - def __init__(self, ticketid, *args, **kwargs): - self.ticketid = ticketid - - @classmethod - async def create(cls, *args, **kwargs): - """ - Method used to create a new ticket of the current type. - Should add a row to the ticket table, post the ticket, and return the Ticket. - """ - raise NotImplementedError - - @property - def data(self): - """ - Ticket row. - This will usually be a row of `ticket_info`. - """ - return data.ticket_info.fetch(self.ticketid) - - @property - def guild(self): - return client.get_guild(self.data.guildid) - - @property - def target(self): - guild = self.guild - return guild.get_member(self.data.targetid) if guild else None - - @property - def msg_args(self): - """ - Ticket message posted in the moderation log. - """ - args = {} - - # Build embed - info = self.data - member = self.target - name = str(member) if member else str(info.targetid) - - if info.auto: - title_fmt = "Ticket #{} | {} | {}[Auto] | {}" - else: - title_fmt = "Ticket #{} | {} | {} | {}" - title = title_fmt.format( - info.guild_ticketid, - TicketState(info.ticket_state).desc, - TicketType(info.ticket_type).desc, - name - ) - - embed = discord.Embed( - title=title, - description=info.content, - timestamp=info.created_at - ) - embed.add_field( - name="Target", - value="<@{}>".format(info.targetid) - ) - - if not info.auto: - embed.add_field( - name="Moderator", - value="<@{}>".format(info.moderator_id) - ) - - # if info.duration: - # value = "`{}` {}".format( - # strfdelta(datetime.timedelta(seconds=info.duration)), - # "(Expiry )".format(info.expiry.timestamp()) if info.expiry else "" - # ) - # embed.add_field( - # name="Duration", - # value=value - # ) - if info.expiry: - if info.ticket_state == TicketState.EXPIRING: - embed.add_field( - name="Expires at", - value="\n(Duration: `{}`)".format( - info.expiry.timestamp(), - strfdelta(datetime.timedelta(seconds=info.duration)) - ) - ) - elif info.ticket_state == TicketState.EXPIRED: - embed.add_field( - name="Expired", - value="".format( - info.expiry.timestamp(), - ) - ) - else: - embed.add_field( - name="Expiry", - value="".format( - info.expiry.timestamp() - ) - ) - - if info.context: - embed.add_field( - name="Context", - value=info.context, - inline=False - ) - - if info.addendum: - embed.add_field( - name="Notes", - value=info.addendum, - inline=False - ) - - if self.state == TicketState.PARDONED: - embed.add_field( - name="Pardoned", - value=( - "Pardoned by <@{}> at .\n{}" - ).format( - info.pardoned_by, - info.pardoned_at.timestamp(), - info.pardoned_reason or "" - ), - inline=False - ) - - embed.set_footer(text="ID: {}".format(info.targetid)) - - args['embed'] = embed - - # Add file - if info.file_name: - args['file'] = discord.File(info.file_data, info.file_name) - - return args - - @property - def link(self): - """ - The link to the ticket in the moderation log. - """ - info = self.data - modlog = GuildSettings(info.guildid).mod_log.data - - return 'https://discord.com/channels/{}/{}/{}'.format( - info.guildid, - modlog, - info.log_msg_id - ) - - @property - def state(self): - return TicketState(self.data.ticket_state) - - @property - def type(self): - return TicketType(self.data.ticket_type) - - async def update(self, **kwargs): - """ - Update ticket fields. - """ - fields = ( - 'targetid', 'moderator_id', 'auto', 'log_msg_id', - 'content', 'expiry', 'ticket_state', - 'context', 'addendum', 'duration', 'file_name', 'file_data', - 'pardoned_by', 'pardoned_at', 'pardoned_reason', - ) - params = {field: kwargs[field] for field in fields if field in kwargs} - if params: - data.ticket_info.update_where(params, ticketid=self.ticketid) - - await self.update_expiry() - await self.post() - - async def post(self): - """ - Post or update the ticket in the moderation log. - Also updates the saved message id. - """ - info = self.data - modlog = GuildSettings(info.guildid).mod_log.value - if not modlog: - return - - resend = True - try: - if info.log_msg_id: - # Try to fetch the message - message = await modlog.fetch_message(info.log_msg_id) - if message: - if message.author.id == client.user.id: - # TODO: Handle file edit - await message.edit(embed=self.msg_args['embed']) - resend = False - else: - try: - await message.delete() - except discord.HTTPException: - pass - - if resend: - message = await modlog.send(**self.msg_args) - self.data.log_msg_id = message.id - except discord.HTTPException: - client.log( - "Cannot post ticket (tid: {}) due to discord exception or issue.".format(self.ticketid) - ) - except Exception: - # This should never happen in normal operation - client.log( - "Error while posting ticket (tid:{})! " - "Exception traceback follows.\n{}".format( - self.ticketid, - traceback.format_exc() - ), - context="TICKETS", - level=logging.ERROR - ) - - @classmethod - def load_expiring(cls): - """ - Load and schedule all expiring tickets. - """ - # TODO: Consider changing this to a flat timestamp system, to avoid storing lots of coroutines. - # TODO: Consider only scheduling the expiries in the next day, and updating this once per day. - # TODO: Only fetch tickets from guilds we are in. - - # Cancel existing expiry tasks - for task in cls._expiry_tasks.values(): - if not task.done(): - task.cancel() - - # Get all expiring tickets - expiring_rows = data.tickets.select_where( - ticket_state=TicketState.EXPIRING, - guildid=THIS_SHARD - ) - - # Create new expiry tasks - now = utc_now() - cls._expiry_tasks = { - row['ticketid']: asyncio.create_task( - cls._schedule_expiry_for( - row['ticketid'], - (row['expiry'] - now).total_seconds() - ) - ) for row in expiring_rows - } - - # Log - client.log( - "Loaded {} expiring tickets.".format(len(cls._expiry_tasks)), - context="TICKET_LOADER", - ) - - @classmethod - async def _schedule_expiry_for(cls, ticketid, delay): - """ - Schedule expiry for a given ticketid - """ - try: - await asyncio.sleep(delay) - ticket = Ticket.fetch(ticketid) - if ticket: - await asyncio.shield(ticket._expire()) - except asyncio.CancelledError: - return - - def update_expiry(self): - # Cancel any existing expiry task - task = self._expiry_tasks.pop(self.ticketid, None) - if task and not task.done(): - task.cancel() - - # Schedule a new expiry task, if applicable - if self.data.ticket_state == TicketState.EXPIRING: - self._expiry_tasks[self.ticketid] = asyncio.create_task( - self._schedule_expiry_for( - self.ticketid, - (self.data.expiry - utc_now()).total_seconds() - ) - ) - - async def cancel_expiry(self): - """ - Cancel ticket expiry. - - In particular, may be used if another ticket overrides `self`. - Sets the ticket state to `OPEN`, so that it no longer expires. - """ - if self.state == TicketState.EXPIRING: - # Update the ticket state - self.data.ticket_state = TicketState.OPEN - - # Remove from expiry tsks - self.update_expiry() - - # Repost - await self.post() - - async def _revert(self, reason=None): - """ - Method used to revert the ticket action, e.g. unban or remove mute role. - Generally called by `pardon` and `_expire`. - - May be overriden by the Ticket type, if they implement any revert logic. - Is a no-op by default. - """ - return - - async def _expire(self): - """ - Method to automatically expire a ticket. - - May be overriden by the Ticket type for more complex expiry logic. - Must set `data.ticket_state` to `EXPIRED` if applicable. - """ - if self.state == TicketState.EXPIRING: - client.log( - "Automatically expiring ticket (tid:{}).".format(self.ticketid), - context="TICKETS" - ) - try: - await self._revert(reason="Automatic Expiry") - except Exception: - # This should never happen in normal operation - client.log( - "Error while expiring ticket (tid:{})! " - "Exception traceback follows.\n{}".format( - self.ticketid, - traceback.format_exc() - ), - context="TICKETS", - level=logging.ERROR - ) - - # Update state - self.data.ticket_state = TicketState.EXPIRED - - # Update log message - await self.post() - - # Post a note to the modlog - modlog = GuildSettings(self.data.guildid).mod_log.value - if modlog: - try: - await modlog.send( - embed=discord.Embed( - colour=discord.Colour.orange(), - description="[Ticket #{}]({}) expired!".format(self.data.guild_ticketid, self.link) - ) - ) - except discord.HTTPException: - pass - - async def pardon(self, moderator, reason, timestamp=None): - """ - Pardon process for the ticket. - - May be overidden by the Ticket type for more complex pardon logic. - Must set `data.ticket_state` to `PARDONED` if applicable. - """ - if self.state != TicketState.PARDONED: - if self.state in (TicketState.OPEN, TicketState.EXPIRING): - try: - await self._revert(reason="Pardoned by {}".format(moderator.id)) - except Exception: - # This should never happen in normal operation - client.log( - "Error while pardoning ticket (tid:{})! " - "Exception traceback follows.\n{}".format( - self.ticketid, - traceback.format_exc() - ), - context="TICKETS", - level=logging.ERROR - ) - - # Update state - with self.data.batch_update(): - self.data.ticket_state = TicketState.PARDONED - self.data.pardoned_at = utc_now() - self.data.pardoned_by = moderator.id - self.data.pardoned_reason = reason - - # Update (i.e. remove) expiry - self.update_expiry() - - # Update log message - await self.post() - - @classmethod - def fetch_tickets(cls, *ticketids, **kwargs): - """ - Fetch tickets matching the given criteria (passed transparently to `select_where`). - Positional arguments are treated as `ticketids`, which are not supported in keyword arguments. - """ - if ticketids: - kwargs['ticketid'] = ticketids - - # Set the ticket type to the class type if not specified - if cls._ticket_type and 'ticket_type' not in kwargs: - kwargs['ticket_type'] = cls._ticket_type - - # This is actually mainly for caching, since we don't pass the data to the initialiser - rows = data.ticket_info.fetch_rows_where( - **kwargs - ) - - return [ - cls._ticket_types[TicketType(row.ticket_type)](row.ticketid) - for row in rows - ] - - @classmethod - def fetch(cls, ticketid): - """ - Return the Ticket with the given id, if found, or `None` otherwise. - """ - tickets = cls.fetch_tickets(ticketid) - return tickets[0] if tickets else None - - @classmethod - def register_ticket_type(cls, ticket_cls): - """ - Decorator to register a new Ticket subclass as a ticket type. - """ - cls._ticket_types[ticket_cls._ticket_type] = ticket_cls - return ticket_cls - - -@module.launch_task -async def load_expiring_tickets(client): - Ticket.load_expiring() diff --git a/src/modules/pending-rewrite/moderation/tickets/__init__.py b/src/modules/pending-rewrite/moderation/tickets/__init__.py deleted file mode 100644 index f9a05faa..00000000 --- a/src/modules/pending-rewrite/moderation/tickets/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .Ticket import Ticket, TicketType, TicketState -from .studybans import StudyBanTicket -from .notes import NoteTicket -from .warns import WarnTicket diff --git a/src/modules/pending-rewrite/moderation/tickets/notes.py b/src/modules/pending-rewrite/moderation/tickets/notes.py deleted file mode 100644 index 7f8ec1e9..00000000 --- a/src/modules/pending-rewrite/moderation/tickets/notes.py +++ /dev/null @@ -1,112 +0,0 @@ -""" -Note ticket implementation. - -Guild moderators can add a note about a user, visible in their moderation history. -Notes appear in the moderation log and the user's ticket history, like any other ticket. - -This module implements the Note TicketType and the `note` moderation command. -""" -from cmdClient.lib import ResponseTimedOut - -from wards import guild_moderator - -from ..module import module -from ..data import tickets - -from .Ticket import Ticket, TicketType, TicketState - - -@Ticket.register_ticket_type -class NoteTicket(Ticket): - _ticket_type = TicketType.NOTE - - @classmethod - async def create(cls, guildid, targetid, moderatorid, content, **kwargs): - """ - Create a new Note on a target. - - `kwargs` are passed transparently to the table insert method. - """ - ticket_row = tickets.insert( - guildid=guildid, - targetid=targetid, - ticket_type=cls._ticket_type, - ticket_state=TicketState.OPEN, - moderator_id=moderatorid, - auto=False, - content=content, - **kwargs - ) - - # Create the note ticket - ticket = cls(ticket_row['ticketid']) - - # Post the ticket and return - await ticket.post() - return ticket - - -@module.cmd( - "note", - group="Moderation", - desc="Add a Note to a member's record." -) -@guild_moderator() -async def cmd_note(ctx): - """ - Usage``: - {prefix}note @target - {prefix}note @target - Description: - Add a note to the target's moderation record. - The note will appear in the moderation log and in the `tickets` command. - - The `target` must be specificed by mention or user id. - If the `content` is not given, it will be prompted for. - Example: - {prefix}note {ctx.author.mention} Seen reading the `note` documentation. - """ - if not ctx.args: - return await ctx.error_reply( - "**Usage:** `{}note @target `.".format(ctx.best_prefix) - ) - - # Extract the target. We don't require them to be in the server - splits = ctx.args.split(maxsplit=1) - target_str = splits[0].strip('<@!&> ') - if not target_str.isdigit(): - return await ctx.error_reply( - "**Usage:** `{}note @target `.\n" - "`target` must be provided by mention or userid.".format(ctx.best_prefix) - ) - targetid = int(target_str) - - # Extract or prompt for the content - if len(splits) != 2: - try: - content = await ctx.input("What note would you like to add?", timeout=300) - except ResponseTimedOut: - raise ResponseTimedOut("Prompt timed out, no note was created.") - else: - content = splits[1].strip() - - # Create the note ticket - ticket = await NoteTicket.create( - ctx.guild.id, - targetid, - ctx.author.id, - content - ) - - if ticket.data.log_msg_id: - await ctx.embed_reply( - "Note on <@{}> created as [Ticket #{}]({}).".format( - targetid, - ticket.data.guild_ticketid, - ticket.link - ) - ) - else: - await ctx.embed_reply( - "Note on <@{}> created as Ticket #{}.".format(targetid, ticket.data.guild_ticketid) - ) diff --git a/src/modules/pending-rewrite/moderation/tickets/studybans.py b/src/modules/pending-rewrite/moderation/tickets/studybans.py deleted file mode 100644 index cc555743..00000000 --- a/src/modules/pending-rewrite/moderation/tickets/studybans.py +++ /dev/null @@ -1,126 +0,0 @@ -import datetime -import discord - -from meta import client -from utils.lib import utc_now -from settings import GuildSettings -from data import NOT - -from .. import data -from .Ticket import Ticket, TicketType, TicketState - - -@Ticket.register_ticket_type -class StudyBanTicket(Ticket): - _ticket_type = TicketType.STUDY_BAN - - @classmethod - async def create(cls, guildid, targetid, moderatorid, reason, expiry=None, **kwargs): - """ - Create a new study ban ticket. - """ - # First create the ticket itself - ticket_row = data.tickets.insert( - guildid=guildid, - targetid=targetid, - ticket_type=cls._ticket_type, - ticket_state=TicketState.EXPIRING if expiry else TicketState.OPEN, - moderator_id=moderatorid, - auto=(moderatorid == client.user.id), - content=reason, - expiry=expiry, - **kwargs - ) - - # Create the Ticket - ticket = cls(ticket_row['ticketid']) - - # Schedule ticket expiry, if applicable - if expiry: - ticket.update_expiry() - - # Cancel any existing studyban expiry for this member - tickets = cls.fetch_tickets( - guildid=guildid, - ticketid=NOT(ticket_row['ticketid']), - targetid=targetid, - ticket_state=TicketState.EXPIRING - ) - for ticket in tickets: - await ticket.cancel_expiry() - - # Post the ticket - await ticket.post() - - # Return the ticket - return ticket - - async def _revert(self, reason=None): - """ - Revert the studyban by removing the role. - """ - guild_settings = GuildSettings(self.data.guildid) - role = guild_settings.studyban_role.value - target = self.target - - if target and role: - try: - await target.remove_roles( - role, - reason="Reverting StudyBan: {}".format(reason) - ) - except discord.HTTPException: - # TODO: Error log? - ... - - @classmethod - async def autoban(cls, guild, target, reason, **kwargs): - """ - Convenience method to automatically studyban a member, for the configured duration. - If the role is set, this will create and return a `StudyBanTicket` regardless of whether the - studyban was successful. - If the role is not set, or the ticket cannot be created, this will return `None`. - """ - # Get the studyban role, fail if there isn't one set, or the role doesn't exist - guild_settings = GuildSettings(guild.id) - role = guild_settings.studyban_role.value - if not role: - return None - - # Attempt to add the role, record failure - try: - await target.add_roles(role, reason="Applying StudyBan: {}".format(reason[:400])) - except discord.HTTPException: - role_failed = True - else: - role_failed = False - - # Calculate the applicable automatic duration and expiry - # First count the existing non-pardoned studybans for this target - studyban_count = data.tickets.select_one_where( - guildid=guild.id, - targetid=target.id, - ticket_type=cls._ticket_type, - ticket_state=NOT(TicketState.PARDONED), - select_columns=('COUNT(*)',) - )[0] - studyban_count = int(studyban_count) - - # Then read the guild setting to find the applicable duration - studyban_durations = guild_settings.studyban_durations.value - if studyban_count < len(studyban_durations): - duration = studyban_durations[studyban_count] - expiry = utc_now() + datetime.timedelta(seconds=duration) - else: - duration = None - expiry = None - - # Create the ticket and return - if role_failed: - kwargs['addendum'] = '\n'.join(( - kwargs.get('addendum', ''), - "Could not add the studyban role! Please add the role manually and check my permissions." - )) - return await cls.create( - guild.id, target.id, client.user.id, reason, duration=duration, expiry=expiry, **kwargs - ) diff --git a/src/modules/pending-rewrite/moderation/tickets/warns.py b/src/modules/pending-rewrite/moderation/tickets/warns.py deleted file mode 100644 index b25c3d2c..00000000 --- a/src/modules/pending-rewrite/moderation/tickets/warns.py +++ /dev/null @@ -1,153 +0,0 @@ -""" -Warn ticket implementation. - -Guild moderators can officially warn a user via command. -This DMs the users with the warning. -""" -import datetime -import discord -from cmdClient.lib import ResponseTimedOut - -from wards import guild_moderator - -from ..module import module -from ..data import tickets - -from .Ticket import Ticket, TicketType, TicketState - - -@Ticket.register_ticket_type -class WarnTicket(Ticket): - _ticket_type = TicketType.WARNING - - @classmethod - async def create(cls, guildid, targetid, moderatorid, content, **kwargs): - """ - Create a new Warning for the target. - - `kwargs` are passed transparently to the table insert method. - """ - ticket_row = tickets.insert( - guildid=guildid, - targetid=targetid, - ticket_type=cls._ticket_type, - ticket_state=TicketState.OPEN, - moderator_id=moderatorid, - content=content, - **kwargs - ) - - # Create the note ticket - ticket = cls(ticket_row['ticketid']) - - # Post the ticket and return - await ticket.post() - return ticket - - async def _revert(*args, **kwargs): - # Warnings don't have a revert process - pass - - -@module.cmd( - "warn", - group="Moderation", - desc="Officially warn a user for a misbehaviour." -) -@guild_moderator() -async def cmd_warn(ctx): - """ - Usage``: - {prefix}warn @target - {prefix}warn @target - Description: - - The `target` must be specificed by mention or user id. - If the `reason` is not given, it will be prompted for. - Example: - {prefix}warn {ctx.author.mention} Don't actually read the documentation! - """ - if not ctx.args: - return await ctx.error_reply( - "**Usage:** `{}warn @target `.".format(ctx.best_prefix) - ) - - # Extract the target. We do require them to be in the server - splits = ctx.args.split(maxsplit=1) - target_str = splits[0].strip('<@!&> ') - if not target_str.isdigit(): - return await ctx.error_reply( - "**Usage:** `{}warn @target `.\n" - "`target` must be provided by mention or userid.".format(ctx.best_prefix) - ) - targetid = int(target_str) - target = ctx.guild.get_member(targetid) - if not target: - return await ctx.error_reply("Cannot warn a user who is not in the server!") - - # Extract or prompt for the content - if len(splits) != 2: - try: - content = await ctx.input("Please give a reason for this warning!", timeout=300) - except ResponseTimedOut: - raise ResponseTimedOut("Prompt timed out, the member was not warned.") - else: - content = splits[1].strip() - - # Create the warn ticket - ticket = await WarnTicket.create( - ctx.guild.id, - targetid, - ctx.author.id, - content - ) - - # Attempt to message the member - embed = discord.Embed( - title="You have received a warning!", - description=( - content - ), - colour=discord.Colour.red(), - timestamp=datetime.datetime.utcnow() - ) - embed.add_field( - name="Info", - value=( - "*Warnings appear in your moderation history. " - "Failure to comply, or repeated warnings, " - "may result in muting, studybanning, or server banning.*" - ) - ) - embed.set_footer( - icon_url=ctx.guild.icon_url, - text=ctx.guild.name - ) - dm_msg = None - try: - dm_msg = await target.send(embed=embed) - except discord.HTTPException: - pass - - # Get previous warnings - count = tickets.select_one_where( - guildid=ctx.guild.id, - targetid=targetid, - ticket_type=TicketType.WARNING, - ticket_state=[TicketState.OPEN, TicketState.EXPIRING], - select_columns=('COUNT(*)',) - )[0] - if count == 1: - prev_str = "This is their first warning." - else: - prev_str = "They now have `{}` warnings.".format(count) - - await ctx.embed_reply( - "[Ticket #{}]({}): {} has been warned. {}\n{}".format( - ticket.data.guild_ticketid, - ticket.link, - target.mention, - prev_str, - "*Could not DM the user their warning!*" if not dm_msg else '' - ) - ) diff --git a/src/modules/pending-rewrite/sponsors/__init__.py b/src/modules/pending-rewrite/sponsors/__init__.py deleted file mode 100644 index 615a9085..00000000 --- a/src/modules/pending-rewrite/sponsors/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from . import module - -from . import data -from . import config -from . import commands diff --git a/src/modules/pending-rewrite/sponsors/commands.py b/src/modules/pending-rewrite/sponsors/commands.py deleted file mode 100644 index 5ddd8b93..00000000 --- a/src/modules/pending-rewrite/sponsors/commands.py +++ /dev/null @@ -1,14 +0,0 @@ -from .module import module - - -@module.cmd( - name="sponsors", - group="Meta", - desc="Check out our wonderful partners!", -) -async def cmd_sponsors(ctx): - """ - Usage``: - {prefix}sponsors - """ - await ctx.reply(**ctx.client.settings.sponsor_message.args(ctx)) diff --git a/src/modules/pending-rewrite/sponsors/config.py b/src/modules/pending-rewrite/sponsors/config.py deleted file mode 100644 index c9d25b56..00000000 --- a/src/modules/pending-rewrite/sponsors/config.py +++ /dev/null @@ -1,92 +0,0 @@ -from cmdClient.checks import is_owner - -from settings import AppSettings, Setting, KeyValueData, ListData -from settings.setting_types import Message, String, GuildIDList - -from meta import client -from core.data import app_config - -from .data import guild_whitelist - -@AppSettings.attach_setting -class sponsor_prompt(String, KeyValueData, Setting): - attr_name = 'sponsor_prompt' - _default = None - - write_ward = is_owner - - display_name = 'sponsor_prompt' - category = 'Sponsors' - desc = "Text to send after core commands to encourage checking `sponsors`." - long_desc = ( - "Text posted after several commands to encourage users to check the `sponsors` command. " - "Occurences of `{{prefix}}` will be replaced by the bot prefix." - ) - - _quote = False - - _table_interface = app_config - _id_column = 'appid' - _key_column = 'key' - _value_column = 'value' - _key = 'sponsor_prompt' - - @classmethod - def _data_to_value(cls, id, data, **kwargs): - if data: - return data.replace("{prefix}", client.prefix) - else: - return None - - @property - def success_response(self): - if self.value: - return "The sponsor prompt has been update." - else: - return "The sponsor prompt has been cleared." - - -@AppSettings.attach_setting -class sponsor_message(Message, KeyValueData, Setting): - attr_name = 'sponsor_message' - _default = '{"content": "Coming Soon!"}' - - write_ward = is_owner - - display_name = 'sponsor_message' - category = 'Sponsors' - desc = "`sponsors` command response." - - long_desc = ( - "Message to reply with when a user runs the `sponsors` command." - ) - - _table_interface = app_config - _id_column = 'appid' - _key_column = 'key' - _value_column = 'value' - _key = 'sponsor_message' - - _cmd_str = "{prefix}sponsors --edit" - - @property - def success_response(self): - return "The `sponsors` command message has been updated." - - -@AppSettings.attach_setting -class sponsor_guild_whitelist(GuildIDList, ListData, Setting): - attr_name = 'sponsor_guild_whitelist' - write_ward = is_owner - - category = 'Sponsors' - display_name = 'sponsor_hidden_in' - desc = "Guilds where the sponsor prompt is not displayed." - long_desc = ( - "A list of guilds where the sponsor prompt hint will be hidden (see the `sponsor_prompt` setting)." - ) - - _table_interface = guild_whitelist - _id_column = 'appid' - _data_column = 'guildid' - _force_unique = True diff --git a/src/modules/pending-rewrite/sponsors/data.py b/src/modules/pending-rewrite/sponsors/data.py deleted file mode 100644 index c3a26d3a..00000000 --- a/src/modules/pending-rewrite/sponsors/data.py +++ /dev/null @@ -1,4 +0,0 @@ -from data import Table - - -guild_whitelist = Table("sponsor_guild_whitelist") diff --git a/src/modules/pending-rewrite/sponsors/module.py b/src/modules/pending-rewrite/sponsors/module.py deleted file mode 100644 index 232eafa6..00000000 --- a/src/modules/pending-rewrite/sponsors/module.py +++ /dev/null @@ -1,33 +0,0 @@ -import discord - -from LionModule import LionModule -from LionContext import LionContext - -from meta import client - - -module = LionModule("Sponsor") - - -sponsored_commands = {'profile', 'stats', 'weekly', 'monthly'} - - -@LionContext.reply.add_wrapper -async def sponsor_reply_wrapper(func, ctx: LionContext, *args, **kwargs): - if ctx.cmd and ctx.cmd.name in sponsored_commands: - if (prompt := ctx.client.settings.sponsor_prompt.value): - if ctx.guild: - show = ctx.guild.id not in ctx.client.settings.sponsor_guild_whitelist.value - show = show and not ctx.client.data.premium_guilds.queries.fetch_guild(ctx.guild.id) - else: - show = True - - if show: - sponsor_hint = discord.Embed( - description=prompt, - colour=discord.Colour.dark_theme() - ) - if 'embed' not in kwargs: - kwargs['embed'] = sponsor_hint - - return await func(ctx, *args, **kwargs) diff --git a/src/modules/pending-rewrite/topgg/__init__.py b/src/modules/pending-rewrite/topgg/__init__.py deleted file mode 100644 index bf762868..00000000 --- a/src/modules/pending-rewrite/topgg/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .module import module - -from . import webhook -from . import commands -from . import data -from . import settings \ No newline at end of file diff --git a/src/modules/pending-rewrite/topgg/commands.py b/src/modules/pending-rewrite/topgg/commands.py deleted file mode 100644 index e70c05ca..00000000 --- a/src/modules/pending-rewrite/topgg/commands.py +++ /dev/null @@ -1,77 +0,0 @@ -import discord -from .module import module -from cmdClient.checks import is_owner -from settings.user_settings import UserSettings -from LionContext import LionContext - -from .webhook import on_dbl_vote -from .utils import lion_loveemote - - -@module.cmd( - "forcevote", - desc="Simulate a Topgg Vote from the given user.", - group="Bot Admin", -) -@is_owner() -async def cmd_forcevote(ctx: LionContext): - """ - Usage``: - {prefix}forcevote - Description: - Simulate Top.gg vote without actually a confirmation from Topgg site. - - Can be used for force a vote for testing or if topgg has an error or production time bot error. - """ - target = ctx.author - - # Identify the target - if ctx.args: - if not ctx.msg.mentions: - return await ctx.error_reply("Please mention a user to simulate a vote!") - target = ctx.msg.mentions[0] - - await on_dbl_vote({"user": target.id, "type": "test"}) - return await ctx.reply('Topgg vote simulation successful on {}'.format(target), suggest_vote=False) - - -@module.cmd( - "vote", - desc="[Vote](https://top.gg/bot/889078613817831495/vote) for me to get 25% more LCs!", - group="Economy", - aliases=('topgg', 'topggvote', 'upvote') -) -async def cmd_vote(ctx: LionContext): - """ - Usage``: - {prefix}vote - Description: - Get Top.gg bot's link for +25% Economy boost. - """ - embed = discord.Embed( - title="Claim your boost!", - description=( - "Please click [here](https://top.gg/bot/889078613817831495/vote) to vote and support our bot!\n\n" - "Thank you! {}.".format(lion_loveemote) - ), - colour=discord.Colour.orange() - ).set_thumbnail( - url="https://cdn.discordapp.com/attachments/908283085999706153/933012309532614666/lion-love.png" - ) - return await ctx.reply(embed=embed, suggest_vote=False) - - -@module.cmd( - "vote_reminder", - group="Personal Settings", - desc="Turn on/off boost reminders." -) -async def cmd_remind_vote(ctx: LionContext): - """ - Usage: - `{prefix}vote_reminder on` - `{prefix}vote_reminder off` - - Enable or disable DM boost reminders. - """ - await UserSettings.settings.vote_remainder.command(ctx, ctx.author.id) diff --git a/src/modules/pending-rewrite/topgg/data.py b/src/modules/pending-rewrite/topgg/data.py deleted file mode 100644 index 3bad8ae9..00000000 --- a/src/modules/pending-rewrite/topgg/data.py +++ /dev/null @@ -1,9 +0,0 @@ -from data.interfaces import RowTable, Table - -topggvotes = RowTable( - 'topgg', - ('voteid', 'userid', 'boostedTimestamp'), - 'voteid' -) - -guild_whitelist = Table('topgg_guild_whitelist') diff --git a/src/modules/pending-rewrite/topgg/module.py b/src/modules/pending-rewrite/topgg/module.py deleted file mode 100644 index 3632e9f1..00000000 --- a/src/modules/pending-rewrite/topgg/module.py +++ /dev/null @@ -1,80 +0,0 @@ -from LionModule import LionModule -from LionContext import LionContext -from core.lion import Lion - -from modules.sponsors.module import sponsored_commands - -from .utils import get_last_voted_timestamp, lion_loveemote, lion_yayemote -from .webhook import init_webhook - -module = LionModule("Topgg") - -upvote_info = "You have a boost available {}, to support our project and earn **25% more LionCoins** type `{}vote` {}" - - -@module.launch_task -async def attach_topgg_webhook(client): - if client.shard_id == 0: - init_webhook() - client.log("Attached top.gg voiting webhook.", context="TOPGG") - - -@module.launch_task -async def register_hook(client): - LionContext.reply.add_wrapper(topgg_reply_wrapper) - Lion.register_economy_bonus(economy_bonus) - - client.log("Loaded top.gg hooks.", context="TOPGG") - - -@module.unload_task -async def unregister_hook(client): - Lion.unregister_economy_bonus(economy_bonus) - LionContext.reply.remove_wrapper(topgg_reply_wrapper.__name__) - - client.log("Unloaded top.gg hooks.", context="TOPGG") - -boostfree_groups = {'Meta'} -boostfree_commands = {'config', 'pomodoro'} - - -async def topgg_reply_wrapper(func, ctx: LionContext, *args, suggest_vote=True, **kwargs): - if not suggest_vote: - pass - elif not ctx.cmd: - pass - elif ctx.cmd.name in boostfree_commands or ctx.cmd.group in boostfree_groups: - pass - elif ctx.guild and ctx.guild.id in ctx.client.settings.topgg_guild_whitelist.value: - pass - elif ctx.guild and ctx.client.data.premium_guilds.queries.fetch_guild(ctx.guild.id): - pass - elif not get_last_voted_timestamp(ctx.author.id): - upvote_info_formatted = upvote_info.format(lion_yayemote, ctx.best_prefix, lion_loveemote) - - if 'embed' in kwargs and ctx.cmd.name not in sponsored_commands: - # Add message as an extra embed field - kwargs['embed'].add_field( - name="\u200b", - value=( - upvote_info_formatted - ), - inline=False - ) - else: - # Add message to content - if 'content' in kwargs and kwargs['content']: - if len(kwargs['content']) + len(upvote_info_formatted) < 1998: - kwargs['content'] += '\n\n' + upvote_info_formatted - elif args: - if len(args[0]) + len(upvote_info_formatted) < 1998: - args = list(args) - args[0] += '\n\n' + upvote_info_formatted - else: - kwargs['content'] = upvote_info_formatted - - return await func(ctx, *args, **kwargs) - - -def economy_bonus(lion): - return 1.25 if get_last_voted_timestamp(lion.userid) else 1 diff --git a/src/modules/pending-rewrite/topgg/settings.py b/src/modules/pending-rewrite/topgg/settings.py deleted file mode 100644 index c59acd90..00000000 --- a/src/modules/pending-rewrite/topgg/settings.py +++ /dev/null @@ -1,72 +0,0 @@ -from cmdClient.checks import is_owner - -from settings import UserSettings, UserSetting, AppSettings -from settings.base import ListData, Setting -from settings.setting_types import Boolean, GuildIDList - -from modules.reminders.reminder import Reminder -from modules.reminders.data import reminders - -from .utils import create_remainder, remainder_content, topgg_upvote_link -from .data import guild_whitelist - - -@UserSettings.attach_setting -class topgg_vote_remainder(Boolean, UserSetting): - attr_name = 'vote_remainder' - _data_column = 'topgg_vote_reminder' - - _default = True - - display_name = 'vote_reminder' - desc = r"Toggle automatic reminders to support me for a 25% LionCoin boost." - long_desc = ( - "Did you know that you can [vote for me]({vote_link}) to help me help other people reach their goals? " - "And you get a **25% boost** to all LionCoin income you make across all servers!\n" - "Enable this setting if you want me to let you know when you can vote again!" - ).format(vote_link=topgg_upvote_link) - - @property - def success_response(self): - if self.value: - # Check if reminder is already running - create_remainder(self.id) - - return ( - "Thank you for supporting me! I will remind in your DMs when you can vote next! " - "(Please make sure your DMs are open, otherwise I can't reach you!)" - ) - else: - # Check if reminder is already running and get its id - r = reminders.select_one_where( - userid=self.id, - select_columns='reminderid', - content=remainder_content, - _extra="ORDER BY remind_at DESC LIMIT 1" - ) - - # Cancel and delete Remainder if already running - if r: - Reminder.delete(r['reminderid']) - - return ( - "I will no longer send you voting reminders." - ) - - -@AppSettings.attach_setting -class topgg_guild_whitelist(GuildIDList, ListData, Setting): - attr_name = 'topgg_guild_whitelist' - write_ward = is_owner - - category = 'Topgg Voting' - display_name = 'topgg_hidden_in' - desc = "Guilds where the topgg vote prompt is not displayed." - long_desc = ( - "A list of guilds where the topgg vote prompt will be hidden." - ) - - _table_interface = guild_whitelist - _id_column = 'appid' - _data_column = 'guildid' - _force_unique = True diff --git a/src/modules/pending-rewrite/topgg/utils.py b/src/modules/pending-rewrite/topgg/utils.py deleted file mode 100644 index d2b91014..00000000 --- a/src/modules/pending-rewrite/topgg/utils.py +++ /dev/null @@ -1,97 +0,0 @@ -import discord -import datetime - -from meta import sharding -from meta import conf -from meta.client import client -from utils.lib import utc_now -from settings.setting_types import Integer - -from modules.reminders.reminder import Reminder -from modules.reminders.data import reminders - -from . import data as db -from data.conditions import GEQ - -topgg_upvote_link = 'https://top.gg/bot/889078613817831495/vote' -remainder_content = ( - "You can now vote again on top.gg!\n" - "Click [here]({}) to vote, thank you for the support!" -).format(topgg_upvote_link) - -lion_loveemote = conf.emojis.getemoji('lionlove') -lion_yayemote = conf.emojis.getemoji('lionyay') - - -def get_last_voted_timestamp(userid: Integer): - """ - Will return None if user has not voted in [-12.5hrs till now] - else will return a Tuple containing timestamp of when exactly she voted - """ - return db.topggvotes.select_one_where( - userid=userid, - select_columns="boostedTimestamp", - boostedTimestamp=GEQ(utc_now() - datetime.timedelta(hours=12.5)), - _extra="ORDER BY boostedTimestamp DESC LIMIT 1" - ) - - -def create_remainder(userid): - """ - Checks if a remainder is already running (immaterial of remind_at time) - If no remainder exists creates a new remainder and schedules it - """ - if not reminders.select_one_where( - userid=userid, - content=remainder_content, - _extra="ORDER BY remind_at DESC LIMIT 1" - ): - last_vote_time = get_last_voted_timestamp(userid) - - # if no, Create reminder - reminder = Reminder.create( - userid=userid, - # TODO using content as a selector is not a good method - content=remainder_content, - message_link=None, - interval=None, - title="Your boost is now available! {}".format(lion_yayemote), - footer="Use `{}vote_reminder off` to stop receiving reminders.".format(client.prefix), - remind_at=( - last_vote_time[0] + datetime.timedelta(hours=12.5) - if last_vote_time else - utc_now() + datetime.timedelta(minutes=5) - ) - # remind_at=datetime.datetime.utcnow() + datetime.timedelta(minutes=2) - ) - - # Schedule reminder - if sharding.shard_number == 0: - reminder.schedule() - - -async def send_user_dm(userid): - # Send the message, if possible - if not (user := client.get_user(userid)): - try: - user = await client.fetch_user(userid) - except discord.HTTPException: - pass - if user: - try: - embed = discord.Embed( - title="Thank you for supporting our bot on Top.gg! {}".format(lion_yayemote), - description=( - "By voting every 12 hours you will allow us to reach and help " - "even more students all over the world.\n" - "Thank you for supporting us, enjoy your LionCoins boost!" - ), - colour=discord.Colour.orange() - ).set_image( - url="https://cdn.discordapp.com/attachments/908283085999706153/932737228440993822/lion-yay.png" - ) - - await user.send(embed=embed) - except discord.HTTPException: - # Nothing we can really do here. Maybe tell the user about their reminder next time? - pass diff --git a/src/modules/pending-rewrite/topgg/webhook.py b/src/modules/pending-rewrite/topgg/webhook.py deleted file mode 100644 index d26cdd80..00000000 --- a/src/modules/pending-rewrite/topgg/webhook.py +++ /dev/null @@ -1,40 +0,0 @@ -from meta.client import client -from settings.user_settings import UserSettings -from utils.lib import utc_now -from meta.config import conf - -import topgg -from .utils import db, send_user_dm, create_remainder - - -@client.event -async def on_dbl_vote(data): - """An event that is called whenever someone votes for the bot on Top.gg.""" - client.log(f"Received a vote: \n{data}", context='Topgg') - - db.topggvotes.insert( - userid=data['user'], - boostedTimestamp=utc_now() - ) - - await send_user_dm(data['user']) - - if UserSettings.settings.vote_remainder.value: - create_remainder(data['user']) - - if data["type"] == "test": - return client.dispatch("dbl_test", data) - - -@client.event -async def on_dbl_test(data): - """An event that is called whenever someone tests the webhook system for your bot on Top.gg.""" - client.log(f"Received a test vote:\n{data}", context='Topgg') - - -def init_webhook(): - client.topgg_webhook = topgg.WebhookManager(client).dbl_webhook( - conf.bot.get("topgg_route"), - conf.bot.get("topgg_password") - ) - client.topgg_webhook.run(conf.bot.get("topgg_port")) diff --git a/src/modules/pending-rewrite/workout/__init__.py b/src/modules/pending-rewrite/workout/__init__.py deleted file mode 100644 index c209e42e..00000000 --- a/src/modules/pending-rewrite/workout/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .module import module - -from . import admin -from . import data -from . import tracker diff --git a/src/modules/pending-rewrite/workout/admin.py b/src/modules/pending-rewrite/workout/admin.py deleted file mode 100644 index d6e1b1f6..00000000 --- a/src/modules/pending-rewrite/workout/admin.py +++ /dev/null @@ -1,83 +0,0 @@ -from settings import GuildSettings, GuildSetting -from wards import guild_admin - -import settings - -from .data import workout_channels - - -@GuildSettings.attach_setting -class workout_length(settings.Integer, GuildSetting): - category = "Workout" - - attr_name = "min_workout_length" - _data_column = "min_workout_length" - - display_name = "min_workout_length" - desc = "Minimum length of a workout." - - _default = 20 - - long_desc = ( - "Minimun time a user must spend in a workout channel for it to count as a valid workout. " - "Value must be given in minutes." - ) - _accepts = "An integer number of minutes." - - @property - def success_response(self): - return "The minimum workout length is now `{}` minutes.".format(self.formatted) - - -@GuildSettings.attach_setting -class workout_reward(settings.Integer, GuildSetting): - category = "Workout" - - attr_name = "workout_reward" - _data_column = "workout_reward" - - display_name = "workout_reward" - desc = "Number of daily LionCoins to reward for completing a workout." - - _default = 350 - - long_desc = ( - "Number of LionCoins given when a member completes their daily workout." - ) - _accepts = "An integer number of LionCoins." - - @property - def success_response(self): - return "The workout reward is now `{}` LionCoins.".format(self.formatted) - - -@GuildSettings.attach_setting -class workout_channels_setting(settings.ChannelList, settings.ListData, settings.Setting): - category = "Workout" - - attr_name = 'workout_channels' - - _table_interface = workout_channels - _id_column = 'guildid' - _data_column = 'channelid' - _setting = settings.VoiceChannel - - write_ward = guild_admin - display_name = "workout_channels" - desc = "Channels in which members can do workouts." - - _force_unique = True - - long_desc = ( - "Sessions in these channels will be treated as workouts." - ) - - # Flat cache, no need to expire objects - _cache = {} - - @property - def success_response(self): - if self.value: - return "The workout channels have been updated:\n{}".format(self.formatted) - else: - return "The workout channels have been removed." diff --git a/src/modules/pending-rewrite/workout/data.py b/src/modules/pending-rewrite/workout/data.py deleted file mode 100644 index 8bc18297..00000000 --- a/src/modules/pending-rewrite/workout/data.py +++ /dev/null @@ -1,10 +0,0 @@ -from data import Table, RowTable - - -workout_channels = Table('workout_channels') - -workout_sessions = RowTable( - 'workout_sessions', - ('sessionid', 'guildid', 'userid', 'start_time', 'duration', 'channelid'), - 'sessionid' -) diff --git a/src/modules/pending-rewrite/workout/module.py b/src/modules/pending-rewrite/workout/module.py deleted file mode 100644 index c214df70..00000000 --- a/src/modules/pending-rewrite/workout/module.py +++ /dev/null @@ -1,4 +0,0 @@ -from LionModule import LionModule - - -module = LionModule("Workout") diff --git a/src/modules/pending-rewrite/workout/tracker.py b/src/modules/pending-rewrite/workout/tracker.py deleted file mode 100644 index a0f19802..00000000 --- a/src/modules/pending-rewrite/workout/tracker.py +++ /dev/null @@ -1,256 +0,0 @@ -import asyncio -import logging -import datetime as dt -import discord - -from core import Lion -from settings import GuildSettings -from meta import client -from data import NULL, tables -from data.conditions import THIS_SHARD - -from .module import module -from .data import workout_sessions -from . import admin - - -leave_tasks = {} - - -async def on_workout_join(member): - key = (member.guild.id, member.id) - - # Cancel a leave task if the member rejoined in time - if member.id in leave_tasks: - leave_tasks[key].cancel() - leave_tasks.pop(key) - return - - # Create a started workout entry - workout = workout_sessions.create_row( - guildid=member.guild.id, - userid=member.id, - channelid=member.voice.channel.id - ) - - # Add to current workouts - client.objects['current_workouts'][key] = workout - - # Log - client.log( - "User '{m.name}'(uid:{m.id}) started a workout in channel " - "'{m.voice.channel.name}' (cid:{m.voice.channel.id}) " - "of guild '{m.guild.name}' (gid:{m.guild.id}).".format(m=member), - context="WORKOUT_STARTED" - ) - GuildSettings(member.guild.id).event_log.log( - "{} started a workout in {}".format( - member.mention, - member.voice.channel.mention - ), title="Workout Started" - ) - - -async def on_workout_leave(member): - key = (member.guild.id, member.id) - - # Create leave task in case of temporary disconnect - task = asyncio.create_task(asyncio.sleep(3)) - leave_tasks[key] = task - - # Wait for the leave task, abort if it gets cancelled - try: - await task - if member.id in leave_tasks: - if leave_tasks[key] == task: - leave_tasks.pop(key) - else: - return - except asyncio.CancelledError: - # Task was cancelled by rejoining - if key in leave_tasks and leave_tasks[key] == task: - leave_tasks.pop(key) - return - - # Retrieve workout row and remove from current workouts - workout = client.objects['current_workouts'].pop(key) - - await workout_left(member, workout) - - -async def workout_left(member, workout): - time_diff = (dt.datetime.utcnow() - workout.start_time).total_seconds() - min_length = GuildSettings(member.guild.id).min_workout_length.value - if time_diff < 60 * min_length: - # Left workout before it was finished. Log and delete - client.log( - "User '{m.name}'(uid:{m.id}) left their workout in guild '{m.guild.name}' (gid:{m.guild.id}) " - "before it was complete! ({diff:.2f} minutes). Deleting workout.\n" - "{workout}".format( - m=member, - diff=time_diff / 60, - workout=workout - ), - context="WORKOUT_ABORTED", - post=True - ) - GuildSettings(member.guild.id).event_log.log( - "{} left their workout before it was complete! (`{:.2f}` minutes)".format( - member.mention, - time_diff / 60, - ), title="Workout Left" - ) - workout_sessions.delete_where(sessionid=workout.sessionid) - else: - # Completed the workout - client.log( - "User '{m.name}'(uid:{m.id}) completed their daily workout in guild '{m.guild.name}' (gid:{m.guild.id}) " - "({diff:.2f} minutes). Saving workout and notifying user.\n" - "{workout}".format( - m=member, - diff=time_diff / 60, - workout=workout - ), - context="WORKOUT_COMPLETED", - post=True - ) - workout.duration = time_diff - await workout_complete(member, workout) - - -async def workout_complete(member, workout): - key = (member.guild.id, member.id) - - # update and notify - user = Lion.fetch(*key) - user_data = user.data - with user_data.batch_update(): - user_data.workout_count = user_data.workout_count + 1 - user_data.last_workout_start = workout.start_time - - settings = GuildSettings(member.guild.id) - reward = settings.workout_reward.value - user.addCoins(reward, bonus=True) - - settings.event_log.log( - "{} completed their daily workout and was rewarded `{}` coins! (`{:.2f}` minutes)".format( - member.mention, - int(reward * user.economy_bonus), - workout.duration / 60, - ), title="Workout Completed" - ) - - embed = discord.Embed( - description=( - "Congratulations on completing your daily workout!\n" - "You have been rewarded with `{}` LionCoins. Good job!".format(int(reward * user.economy_bonus)) - ), - timestamp=dt.datetime.utcnow(), - colour=discord.Color.orange() - ) - embed.set_footer( - text=member.guild.name, - icon_url=member.guild.icon_url - ) - try: - await member.send(embed=embed) - except discord.Forbidden: - client.log( - "Couldn't notify user '{m.name}'(uid:{m.id}) about their completed workout! " - "They might have me blocked.".format(m=member), - context="WORKOUT_COMPLETED", - post=True - ) - - -@client.add_after_event("voice_state_update") -async def workout_voice_tracker(client, member, before, after): - # Wait until launch tasks are complete - while not module.ready: - await asyncio.sleep(0.1) - - if member.bot: - return - if member.id in client.user_blacklist(): - return - if member.id in client.objects['ignored_members'][member.guild.id]: - return - - # Check whether we are moving to/from a workout channel - settings = GuildSettings(member.guild.id) - channels = settings.workout_channels.value - from_workout = before.channel in channels - to_workout = after.channel in channels - - if to_workout ^ from_workout: - # Ensure guild row exists - tables.guild_config.fetch_or_create(member.guild.id) - - # Fetch workout user - user = Lion.fetch(member.guild.id, member.id) - - # Ignore all workout events from users who have already completed their workout today - if user.data.last_workout_start is not None: - last_date = user.localize(user.data.last_workout_start).date() - today = user.localize(dt.datetime.utcnow()).date() - if last_date == today: - return - - # TODO: Check if they have completed a workout today, if so, ignore - if to_workout and not from_workout: - await on_workout_join(member) - elif from_workout and not to_workout: - if (member.guild.id, member.id) in client.objects['current_workouts']: - await on_workout_leave(member) - else: - client.log( - "Possible missed workout!\n" - "Member '{m.name}'(uid:{m.id}) left the workout channel '{c.name}'(cid:{c.id}) " - "in guild '{m.guild.name}'(gid:{m.guild.id}), but we never saw them join!".format( - m=member, - c=before.channel - ), - context="WORKOUT_TRACKER", - level=logging.ERROR, - post=True - ) - settings.event_log.log( - "{} left the workout channel {}, but I never saw them join!".format( - member.mention, - before.channel.mention, - ), title="Possible Missed Workout!" - ) - - -@module.launch_task -async def load_workouts(client): - client.objects['current_workouts'] = {} # (guildid, userid) -> Row - # Process any incomplete workouts - workouts = workout_sessions.fetch_rows_where( - duration=NULL, - guildid=THIS_SHARD - ) - count = 0 - for workout in workouts: - channelids = admin.workout_channels_setting.get(workout.guildid).data - member = Lion.fetch(workout.guildid, workout.userid).member - if member: - if member.voice and (member.voice.channel.id in channelids): - client.objects['current_workouts'][(workout.guildid, workout.userid)] = workout - count += 1 - else: - asyncio.create_task(workout_left(member, workout)) - else: - client.log( - "Removing incomplete workout from " - "non-existent member (mid:{}) in guild (gid:{})".format( - workout.userid, - workout.guildid - ), - context="WORKOUT_LAUNCH", - post=True - ) - if count > 0: - client.log( - "Loaded {} in-progress workouts.".format(count), context="WORKOUT_LAUNCH", post=True - ) diff --git a/src/modules/pomodoro/cog.py b/src/modules/pomodoro/cog.py index 6c1f5f8f..ea681502 100644 --- a/src/modules/pomodoro/cog.py +++ b/src/modules/pomodoro/cog.py @@ -90,7 +90,7 @@ class TimerCog(LionCog): self.bot.core.guild_config.register_model_setting(self.settings.PomodoroChannel) configcog = self.bot.get_cog('ConfigCog') - self.crossload_group(self.configure_group, configcog.configure_group) + self.crossload_group(self.configure_group, configcog.config_group) if self.bot.is_ready(): await self.initialise() @@ -979,7 +979,6 @@ class TimerCog(LionCog): @appcmds.describe( pomodoro_channel=TimerSettings.PomodoroChannel._desc ) - @appcmds.default_permissions(manage_guild=True) @low_management_ward async def configure_pomodoro_command(self, ctx: LionContext, pomodoro_channel: Optional[discord.VoiceChannel | discord.TextChannel] = None): diff --git a/src/modules/pomodoro/graphics.py b/src/modules/pomodoro/graphics.py index 1d8904e2..7fc9de8b 100644 --- a/src/modules/pomodoro/graphics.py +++ b/src/modules/pomodoro/graphics.py @@ -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, diff --git a/src/modules/pomodoro/settings.py b/src/modules/pomodoro/settings.py index 9c6fc6bf..4200d2a1 100644 --- a/src/modules/pomodoro/settings.py +++ b/src/modules/pomodoro/settings.py @@ -4,6 +4,7 @@ from settings.setting_types import ChannelSetting from core.data import CoreData from babel.translator import ctx_translator +from wards import low_management_iward from . import babel @@ -14,7 +15,8 @@ class TimerSettings(SettingGroup): class PomodoroChannel(ModelData, ChannelSetting): setting_id = 'pomodoro_channel' _event = 'guildset_pomodoro_channel' - _set_cmd = 'configure pomodoro' + _set_cmd = 'config pomodoro' + _write_ward = low_management_iward _display_name = _p('guildset:pomodoro_channel', "pomodoro_channel") _desc = _p( diff --git a/src/modules/pomodoro/settingui.py b/src/modules/pomodoro/settingui.py index fbdeedf1..4e29f88d 100644 --- a/src/modules/pomodoro/settingui.py +++ b/src/modules/pomodoro/settingui.py @@ -30,6 +30,7 @@ class TimerConfigUI(ConfigUI): async def channel_menu(self, selection: discord.Interaction, selected: ChannelSelect): await selection.response.defer() setting = self.instances[0] + await setting.interaction_check(setting.parent_id, selection) setting.value = selected.values[0] if selected.values else None await setting.write() @@ -78,7 +79,7 @@ class TimerConfigUI(ConfigUI): class TimerDashboard(DashboardSection): section_name = _p( 'dash:pomodoro|title', - "Pomodoro Configuration ({commands[configure pomodoro]})" + "Pomodoro Configuration ({commands[config pomodoro]})" ) _option_name = _p( "dash:stats|dropdown|placeholder", diff --git a/src/modules/premium/__init__.py b/src/modules/premium/__init__.py new file mode 100644 index 00000000..222b76bb --- /dev/null +++ b/src/modules/premium/__init__.py @@ -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)) diff --git a/src/modules/premium/cog.py b/src/modules/premium/cog.py new file mode 100644 index 00000000..5eb40164 --- /dev/null +++ b/src/modules/premium/cog.py @@ -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) diff --git a/src/modules/premium/data.py b/src/modules/premium/data.py new file mode 100644 index 00000000..0643bee0 --- /dev/null +++ b/src/modules/premium/data.py @@ -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') + diff --git a/src/modules/premium/errors.py b/src/modules/premium/errors.py new file mode 100644 index 00000000..043817de --- /dev/null +++ b/src/modules/premium/errors.py @@ -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 diff --git a/src/modules/premium/ui/premium.py b/src/modules/premium/ui/premium.py new file mode 100644 index 00000000..3a5dd9a3 --- /dev/null +++ b/src/modules/premium/ui/premium.py @@ -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() diff --git a/src/modules/premium/ui/transactions.py b/src/modules/premium/ui/transactions.py new file mode 100644 index 00000000..012a9784 --- /dev/null +++ b/src/modules/premium/ui/transactions.py @@ -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 [[]] diff --git a/src/modules/ranks/cog.py b/src/modules/ranks/cog.py index 7d1da652..a204d0a4 100644 --- a/src/modules/ranks/cog.py +++ b/src/modules/ranks/cog.py @@ -140,7 +140,7 @@ class RankCog(LionCog): self.bot.core.guild_config.register_model_setting(self.settings.DMRanks) configcog = self.bot.get_cog('ConfigCog') - self.crossload_group(self.configure_group, configcog.configure_group) + self.crossload_group(self.configure_group, configcog.admin_config_group) def ranklock(self, guildid): lock = self._rank_locks.get(guildid, None) @@ -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, @@ -926,7 +931,6 @@ class RankCog(LionCog): dm_ranks=RankSettings.DMRanks._desc, rank_channel=RankSettings.RankChannel._desc, ) - @appcmds.default_permissions(administrator=True) @high_management_ward async def configure_ranks_cmd(self, ctx: LionContext, rank_type: Optional[Transformed[RankTypeChoice, AppCommandOptionType.string]] = None, diff --git a/src/modules/ranks/settings.py b/src/modules/ranks/settings.py index b98960b3..ff72c4d3 100644 --- a/src/modules/ranks/settings.py +++ b/src/modules/ranks/settings.py @@ -4,6 +4,7 @@ from settings.setting_types import BoolSetting, ChannelSetting, EnumSetting from core.data import RankType, CoreData from babel.translator import ctx_translator +from wards import high_management_iward from . import babel @@ -40,7 +41,8 @@ class RankSettings(SettingGroup): setting_id = 'rank_type' _event = 'guildset_rank_type' - _set_cmd = 'configure ranks' + _set_cmd = 'admin config ranks' + _write_ward = high_management_iward _display_name = _p('guildset:rank_type', "rank_type") _desc = _p( @@ -98,7 +100,8 @@ class RankSettings(SettingGroup): If DMRanks is set, this will only be used when the target user has disabled DM notifications. """ setting_id = 'rank_channel' - _set_cmd = 'configure ranks' + _set_cmd = 'admin config ranks' + _write_ward = high_management_iward _display_name = _p('guildset:rank_channel', "rank_channel") _desc = _p( @@ -148,7 +151,8 @@ class RankSettings(SettingGroup): Whether to DM rank notifications. """ setting_id = 'dm_ranks' - _set_cmd = 'configure ranks' + _set_cmd = 'admin config ranks' + _write_ward = high_management_iward _display_name = _p('guildset:dm_ranks', "dm_ranks") _desc = _p( diff --git a/src/modules/ranks/ui/config.py b/src/modules/ranks/ui/config.py index 1dbf1aa3..d84ffd52 100644 --- a/src/modules/ranks/ui/config.py +++ b/src/modules/ranks/ui/config.py @@ -69,6 +69,7 @@ class RankConfigUI(ConfigUI): async def type_menu(self, selection: discord.Interaction, selected: Select): await selection.response.defer(thinking=True) setting = self.instances[0] + await setting.interaction_check(setting.parent_id, selection) value = selected.values[0] data = RankType((value,)) setting.data = data @@ -117,6 +118,7 @@ class RankConfigUI(ConfigUI): async def channel_menu(self, selection: discord.Interaction, selected: ChannelSelect): await selection.response.defer() setting = self.instances[2] + await setting.interaction_check(setting.parent_id, selection) setting.value = selected.values[0] if selected.values else None await setting.write() @@ -168,7 +170,7 @@ class RankConfigUI(ConfigUI): class RankDashboard(DashboardSection): section_name = _p( 'dash:rank|title', - "Rank Configuration ({commands[configure ranks]})", + "Rank Configuration ({commands[admin config ranks]})", ) _option_name = _p( "dash:rank|dropdown|placeholder", diff --git a/src/modules/ranks/ui/overview.py b/src/modules/ranks/ui/overview.py index 9f87e78a..b551ee58 100644 --- a/src/modules/ranks/ui/overview.py +++ b/src/modules/ranks/ui/overview.py @@ -20,6 +20,7 @@ from ..data import AnyRankData, RankData from ..utils import rank_model_from_type, format_stat_range, stat_data_to_value from .editor import RankEditor from .preview import RankPreviewUI +from .templates import get_guild_template _p = babel._p @@ -87,7 +88,73 @@ class RankOverviewUI(MessageUI): Ranks are determined by rank type. """ - await press.response.send_message("Not Implemented Yet") + t = self.bot.translator.t + + # Prevent role creation spam + if await self.rank_model.table.select_where(guildid=self.guild.id): + return await press.response.send_message(content=t(_p( + 'ui:rank_overview|button:auto|error:already_created', + "The rank roles have already been created!" + )), ephemeral=True) + + await press.response.defer(thinking=True) + + if not self.guild.me.guild_permissions.manage_roles: + raise SafeCancellation(t(_p( + 'ui:rank_overview|button:auto|error:my_permissions', + "I lack the 'Manage Roles' permission required to create rank roles!" + ))) + + # Get rank role template based on set RankType and VoiceMode + template = get_guild_template(self.rank_type, self.lguild.guild_mode.voice) + if not template: + # Safely error if rank type or voice mode isn't an expected value + raise SafeCancellation(t(_p( + 'ui:rank_overview|button:auto|error:invalid_template', + "Unable to determine rank role template!"))) + + roles = [] + async with self.cog.ranklock(self.guild.id): + for rank in reversed(template): + try: + colour = discord.Colour.from_str(rank.colour) + role = await self.guild.create_role(name=t(rank.name), colour=colour) + roles.append(role) + await self.rank_model.create( + roleid=role.id, + guildid=self.guild.id, + required=rank.required, + reward=rank.reward, + message=t(rank.message) + ) + self.cog.flush_guild_ranks(self.guild.id) + + # Error if manage roles is lost during the process. This shouldn't happen + except discord.Forbidden: + self.cog.flush_guild_ranks(self.guild.id) + raise SafeCancellation(t(_p( + 'ui:rank_overview|button|auto|role_creation|error:forbidden', + "An error occurred while autocreating rank roles!\n" + "I lack the 'Manage Roles' permission required to create rank roles!" + ))) + + except discord.HTTPException: + self.cog.flush_guild_ranks(self.guild.id) + raise SafeCancellation(t(_p( + 'ui:rank_overview|button:auto|role_creation|error:unknown', + "An error occurred while autocreating rank roles!\n" + "Please check the server has enough space for new roles " + "and try again." + ))) + + success_msg = t(_p( + 'ui:rank_overview|button:auto|role_creation|success', + "Successfully created the following rank roles:\n{roles}" + )).format(roles="\n".join(role.mention for role in roles)) + embed = discord.Embed( + colour=discord.Colour.brand_green(), + description=success_msg) + await press.edit_original_response(embed=embed) async def auto_button_refresh(self): self.auto_button.label = self.bot.translator.t(_p( @@ -384,11 +451,17 @@ class RankOverviewUI(MessageUI): # No ranks, give hints about adding ranks desc = t(_p( 'ui:rank_overview|embed:noranks|desc', - "No activity ranks have been set up!\n" - "Press 'AUTO' to automatically create a " - "standard heirachy of voice | text | xp ranks, " - "or select a role or press Create below!" + "No activity ranks have been set up!" )) + if show_note: + auto_addendum = t(_p( + 'ui:rank_overview|embed:noranks|desc|admin_addendum', + "Press 'Auto Create' to automatically create a " + "standard heirachy of ranks.\n" + "To manually create ranks, press 'Create Rank' below, or select a role!" + )) + desc = "\n".join((desc, auto_addendum)) + if self.rank_type is RankType.VOICE: title = t(_p( 'ui:rank_overview|embed|title|type:voice', @@ -430,7 +503,7 @@ class RankOverviewUI(MessageUI): "Ranks are determined by *all-time* statistics.\n" "To reward ranks from a later time (e.g. to have monthly/quarterly/yearly ranks) " "set the `season_start` with {stats_cmd}" - )).format(stats_cmd=self.bot.core.mention_cmd('configure statistics')) + )).format(stats_cmd=self.bot.core.mention_cmd('admin config statistics')) if self.rank_type is RankType.VOICE: addendum = t(_p( 'ui:rank_overview|embed|field:note|value|voice_addendum', diff --git a/src/modules/ranks/ui/templates.py b/src/modules/ranks/ui/templates.py new file mode 100644 index 00000000..99d836d2 --- /dev/null +++ b/src/modules/ranks/ui/templates.py @@ -0,0 +1,303 @@ +from collections import namedtuple +from core.data import RankType +from core.lion_guild import VoiceMode + +from meta import conf, LionBot +from babel.translator import ctx_translator + +from .. import babel + +_p = babel._p + +RankBase = namedtuple("RankBase", ("name", "required", "reward", "message", "colour")) + +""" +Reward message defaults +""" + +voice_reward_msg = _p( + 'ui:rank_editor|input:message|default|type:voice', + "Congratulations {user_mention}!\n" + "For working hard for **{requires}**, you have achieved the rank of " + "**{role_name}** in **{guild_name}**! Keep up the good work." +) + +xp_reward_msg = _p( + 'ui:rank_editor|input:message|default|type:xp', + "Congratulations {user_mention}!\n" + "For earning **{requires}**, you have achieved the guild rank of " + "**{role_name}** in **{guild_name}**!" +) + +msg_reward_msg = _p( + 'ui:rank_editor|input:message|default|type:msg', + "Congratulations {user_mention}!\n" + "For sending **{requires}**, you have achieved the guild rank of " + "**{role_name}** in **{guild_name}**!" +) + + +""" +Rank templates based on voice activity +""" + +study_voice_template = [ + RankBase( + name=_p('rank_autocreate|template|type:study_voice|level:1', + "Voice Level 1 (1h)"), + required=3600, + reward=1000, + message=voice_reward_msg, + colour="#1f28e2" + ), + RankBase( + name=_p('rank_autocreate|template|type:study_voice|level:2', + "Voice Level 2 (3h)"), + required=10800, + reward=2000, + message=voice_reward_msg, + colour="#006bff" + ), + RankBase( + name=_p('rank_autocreate|template|type:study_voice|level:3', + "Voice Level 3 (6h)"), + required=21600, + reward=3000, + message=voice_reward_msg, + colour="#0091ff" + ), + RankBase( + name=_p('rank_autocreate|template|type:study_voice|level:4', + "Voice Level 4 (10h)"), + required=36000, + reward=4000, + message=voice_reward_msg, + colour="#00adf5" + ), + RankBase( + name=_p('rank_autocreate|template|type:study_voice|level:5', + "Voice Level 5 (20h)"), + required=72000, + reward=5000, + message=voice_reward_msg, + colour="#00c6bf" + ), + RankBase( + name=_p('rank_autocreate|template|type:study_voice|level:6', + "Voice Level 6 (40h)"), + required=144000, + reward=6000, + message=voice_reward_msg, + colour="#00db86" + ), + RankBase( + name=_p('rank_autocreate|template|type:study_voice|level:7', + "Voice Level 7 (80h)"), + required=288000, + reward=7000, + message=voice_reward_msg, + colour="#7cea5a" + ) +] + +general_voice_template = [ + RankBase( + name=_p('rank_autocreate|template|type:general_voice|level:1', + "Voice Level 1 (1h)"), + required=3600, + reward=1000, + message=voice_reward_msg, + colour="#1f28e2" + ), + RankBase( + name=_p('rank_autocreate|template|type:general_voice|level:2', + "Voice Level 2 (2h)"), + required=7200, + reward=2000, + message=voice_reward_msg, + colour="#006bff" + ), + RankBase( + name=_p('rank_autocreate|template|type:general_voice|level:3', + "Voice Level 3 (4h)"), + required=14400, + reward=3000, + message=voice_reward_msg, + colour="#0091ff" + ), + RankBase( + name=_p('rank_autocreate|template|type:general_voice|level:4', + "Voice Level 4 (8h)"), + required=28800, + reward=4000, + message=voice_reward_msg, + colour="#00adf5" + ), + RankBase( + name=_p('rank_autocreate|template|type:general_voice|level:5', + "Voice Level 5 (16h)"), + required=57600, + reward=5000, + message=voice_reward_msg, + colour="#00c6bf" + ), + RankBase( + name=_p('rank_autocreate|template|type:general_voice|level:6', + "Voice Level 6 (32h)"), + required=115200, + reward=6000, + message=voice_reward_msg, + colour="#00db86" + ), + RankBase( + name=_p('rank_autocreate|template|type:general_voice|level:7', + "Voice Level 7 (64h)"), + required=230400, + reward=7000, + message=voice_reward_msg, + colour="#7cea5a" + ) +] + +""" +Rank templates based on message XP earned +""" + +xp_template = [ + RankBase( + name=_p('rank_autocreate|template|type:xp|level:1', + "XP Level 1 (2000)"), + required=2000, + reward=1000, + message=xp_reward_msg, + colour="#1f28e2" + ), + RankBase( + name=_p('rank_autocreate|template|type:xp|level:2', + "XP Level 2 (4000)"), + required=4000, + reward=2000, + message=xp_reward_msg, + colour="#006bff" + ), + RankBase( + name=_p('rank_autocreate|template|type:xp|level:3', + "XP Level 3 (8000)"), + required=8000, + reward=3000, + message=xp_reward_msg, + colour="#0091ff" + ), + RankBase( + name=_p('rank_autocreate|template|type:xp|level:4', + "XP Level 4 (16000)"), + required=16000, + reward=4000, + message=xp_reward_msg, + colour="#00adf5" + ), + RankBase( + name=_p('rank_autocreate|template|type:xp|level:5', + "XP Level 5 (32000)"), + required=32000, + reward=5000, + message=xp_reward_msg, + colour="#00c6bf" + ), + RankBase( + name=_p('rank_autocreate|template|type:xp|level:6', + "XP Level 6 (64000)"), + required=64000, + reward=6000, + message=xp_reward_msg, + colour="#00db86" + ), + RankBase( + name=_p('rank_autocreate|template|type:xp|level:7', + "XP Level 7 (128000)"), + required=128000, + reward=7000, + message=xp_reward_msg, + colour="#7cea5a" + ) +] + +""" +Rank templates based on messages sent +""" + +msg_template = [ + RankBase( + name=_p('rank_autocreate|template|type:msg|level:1', + "Message Level 1 (200)"), + required=200, + reward=1000, + message=msg_reward_msg, + colour="#1f28e2" + ), + RankBase( + name=_p('rank_autocreate|template|type:msg|level:2', + "Message Level 2 (400)"), + required=400, + reward=2000, + message=msg_reward_msg, + colour="#006bff" + ), + RankBase( + name=_p('rank_autocreate|template|type:msg|level:3', + "Message Level 3 (800)"), + required=800, + reward=3000, + message=msg_reward_msg, + colour="#0091ff" + ), + RankBase( + name=_p('rank_autocreate|template|type:msg|level:4', + "Message Level 4 (1600)"), + required=1600, + reward=4000, + message=msg_reward_msg, + colour="#00adf5" + ), + RankBase( + name=_p('rank_autocreate|template|type:msg|level:5', + "Message Level 5 (3200)"), + required=3200, + reward=5000, + message=msg_reward_msg, + colour="#00c6bf" + ), + RankBase( + name=_p('rank_autocreate|template|type:msg|level:6', + "Message Level 6 (6400)"), + required=6400, + reward=6000, + message=msg_reward_msg, + colour="#00db86" + ), + RankBase( + name=_p('rank_autocreate|template|type:msg|level:7', + "Message Level 7 (12800)"), + required=12800, + reward=7000, + message=msg_reward_msg, + colour="#7cea5a" + ) +] + + +def get_guild_template(rank_type: RankType, voice_mode: VoiceMode): + """ + Returns the best fit rank template + based on the guild's rank type and voice mode. + """ + if rank_type == RankType.VOICE: + if voice_mode == VoiceMode.STUDY: + return study_voice_template + if voice_mode == VoiceMode.VOICE: + return general_voice_template + if rank_type == RankType.XP: + return xp_template + if rank_type == RankType.MESSAGE: + return msg_template + return None diff --git a/src/modules/reminders/cog.py b/src/modules/reminders/cog.py index 3382179b..3bdd8cc9 100644 --- a/src/modules/reminders/cog.py +++ b/src/modules/reminders/cog.py @@ -332,6 +332,7 @@ class Reminders(LionCog): "View and set your reminders." ) ) + @appcmds.guild_only async def cmd_reminders(self, ctx: LionContext): """ Display the reminder widget for this user. @@ -353,6 +354,7 @@ class Reminders(LionCog): name=_p('cmd:remindme', "remindme"), description=_p('cmd:remindme|desc', "View and set task reminders."), ) + @appcmds.guild_only async def remindme_group(self, ctx: LionContext): # Base command group for scheduling reminders. pass diff --git a/src/modules/rooms/cog.py b/src/modules/rooms/cog.py index 3490848a..b5c57ff8 100644 --- a/src/modules/rooms/cog.py +++ b/src/modules/rooms/cog.py @@ -16,7 +16,7 @@ from utils.ui import Confirm from constants import MAX_COINS from core.data import CoreData -from wards import low_management_ward +from wards import high_management_ward from . import babel, logger from .data import RoomData @@ -47,7 +47,7 @@ class RoomCog(LionCog): self.bot.core.guild_config.register_model_setting(setting) configcog = self.bot.get_cog('ConfigCog') - self.crossload_group(self.configure_group, configcog.configure_group) + self.crossload_group(self.configure_group, configcog.admin_config_group) if self.bot.is_ready(): await self.initialise() @@ -414,7 +414,7 @@ class RoomCog(LionCog): t(_p( 'cmd:room_rent|error:not_setup', "The private room system has not been set up! " - "A private room category needs to be set first with `/configure rooms`." + "A private room category needs to be set first with `/admin config rooms`." )) ), ephemeral=True ) @@ -523,12 +523,31 @@ class RoomCog(LionCog): self._start(room) # Send tips message - # TODO: Actual tips. - await room.channel.send( - "{mention} welcome to your private room! You may use the menu below to configure it.".format( - mention=ctx.author.mention - ) + tips = ( + "Welcome to your very own private room {owner}!\n" + "You may use the control panel below to quickly configure your room, including:\n" + "- Inviting (and removing) members,\n" + "- Depositing LionCoins into the room bank to pay the daily rent, and\n" + "- Adding your very own Pomodoro timer to the room.\n\n" + "You also have elevated Discord permissions over the room itself!\n" + "This includes managing messages, and changing the name, region," + " and bitrate of the channel, or even deleting the room entirely!" + " (Beware you will not be refunded in this case.)\n\n" + "Finally, you now have access to some new commands:\n" + "{status_cmd}: This brings up the room control panel again," + " in case the interface below times out or is deleted/hidden.\n" + "{deposit_cmd}: Room members may use this command to easily contribute LionCoins to the room bank.\n" + "{invite_cmd} and {kick_cmd}: Quickly invite (or remove) multiple members by mentioning them.\n" + "{transfer_cmd}: Transfer the room to another owner, keeping the balance (this is not reversible!)" + ).format( + owner=ctx.author.mention, + status_cmd=self.bot.core.mention_cmd('room status'), + deposit_cmd=self.bot.core.mention_cmd('room deposit'), + invite_cmd=self.bot.core.mention_cmd('room invite'), + kick_cmd=self.bot.core.mention_cmd('room kick'), + transfer_cmd=self.bot.core.mention_cmd('room transfer'), ) + await room.channel.send(tips) # Send config UI ui = RoomUI(self.bot, room, callerid=ctx.author.id, timeout=None) @@ -987,8 +1006,7 @@ class RoomCog(LionCog): @appcmds.describe( **{setting.setting_id: setting._desc for setting in RoomSettings.model_settings} ) - @appcmds.default_permissions(manage_guild=True) - @low_management_ward + @high_management_ward async def configure_rooms_cmd(self, ctx: LionContext, rooms_category: Optional[discord.CategoryChannel] = None, rooms_price: Optional[Range[int, 0, MAX_COINS]] = None, diff --git a/src/modules/rooms/settings.py b/src/modules/rooms/settings.py index e8e38144..0d8d9cce 100644 --- a/src/modules/rooms/settings.py +++ b/src/modules/rooms/settings.py @@ -5,6 +5,7 @@ from settings.setting_types import ChannelSetting, IntegerSetting, BoolSetting from meta import conf from core.data import CoreData from babel.translator import ctx_translator +from wards import low_management_iward, high_management_iward from . import babel @@ -15,7 +16,8 @@ class RoomSettings(SettingGroup): class Category(ModelData, ChannelSetting): setting_id = 'rooms_category' _event = 'guildset_rooms_category' - _set_cmd = 'configure rooms' + _set_cmd = 'admin config rooms' + _write_ward = high_management_iward _display_name = _p( 'guildset:room_category', "rooms_category" @@ -70,7 +72,8 @@ class RoomSettings(SettingGroup): class Rent(ModelData, IntegerSetting): setting_id = 'rooms_price' _event = 'guildset_rooms_price' - _set_cmd = 'configure rooms' + _set_cmd = 'admin config rooms' + _write_ward = low_management_iward _display_name = _p( 'guildset:rooms_price', "room_rent" @@ -107,7 +110,8 @@ class RoomSettings(SettingGroup): class MemberLimit(ModelData, IntegerSetting): setting_id = 'rooms_slots' _event = 'guildset_rooms_slots' - _set_cmd = 'configure rooms' + _set_cmd = 'admin config rooms' + _write_ward = low_management_iward _display_name = _p('guildset:rooms_slots', "room_member_cap") _desc = _p( @@ -141,7 +145,8 @@ class RoomSettings(SettingGroup): class Visible(ModelData, BoolSetting): setting_id = 'rooms_visible' _event = 'guildset_rooms_visible' - _set_cmd = 'configure rooms' + _set_cmd = 'admin config rooms' + _write_ward = high_management_iward _display_name = _p('guildset:rooms_visible', "room_visibility") _desc = _p( diff --git a/src/modules/rooms/settingui.py b/src/modules/rooms/settingui.py index becfb51e..e51d2c7c 100644 --- a/src/modules/rooms/settingui.py +++ b/src/modules/rooms/settingui.py @@ -29,6 +29,7 @@ class RoomSettingUI(ConfigUI): async def category_menu(self, selection: discord.Interaction, selected: ChannelSelect): await selection.response.defer() setting = self.instances[0] + await setting.interaction_check(setting.parent_id, selection) setting.value = selected.values[0] if selected.values else None await setting.write() @@ -42,6 +43,7 @@ class RoomSettingUI(ConfigUI): async def visible_button(self, press: discord.Interaction, pressed: Button): await press.response.defer() setting = next(inst for inst in self.instances if inst.setting_id == RoomSettings.Visible.setting_id) + await setting.interaction_check(setting.parent_id, press) setting.value = not setting.value await setting.write() @@ -95,7 +97,7 @@ class RoomSettingUI(ConfigUI): class RoomDashboard(DashboardSection): section_name = _p( 'dash:rooms|title', - "Private Room Configuration ({commands[configure rooms]})" + "Private Room Configuration ({commands[admin config rooms]})" ) _option_name = _p( "dash:economy|dropdown|placeholder", diff --git a/src/modules/schedule/cog.py b/src/modules/schedule/cog.py index 1208f562..a974bb1a 100644 --- a/src/modules/schedule/cog.py +++ b/src/modules/schedule/cog.py @@ -17,7 +17,7 @@ from meta.monitor import ComponentMonitor, ComponentStatus, StatusLevel from utils.lib import utc_now, error_embed from utils.ui import Confirm from utils.data import MULTIVALUE_IN, MEMBERS -from wards import low_management_ward +from wards import high_management_ward from core.data import CoreData from data import NULL, ORDER from modules.economy.data import TransactionType @@ -118,7 +118,7 @@ class ScheduleCog(LionCog): await self.settings.SessionChannels.setup(self.bot) configcog = self.bot.get_cog('ConfigCog') - self.crossload_group(self.configure_group, configcog.configure_group) + self.crossload_group(self.configure_group, configcog.admin_config_group) if self.bot.is_ready(): await self.initialise() @@ -1090,7 +1090,7 @@ class ScheduleCog(LionCog): @appcmds.describe( **{param: option._desc for param, option in config_params.items()} ) - @low_management_ward + @high_management_ward async def configure_schedule_command(self, ctx: LionContext, session_lobby: Optional[discord.TextChannel | discord.VoiceChannel] = None, session_room: Optional[discord.VoiceChannel] = None, diff --git a/src/modules/schedule/settings.py b/src/modules/schedule/settings.py index 921ec8a0..ac5f5e95 100644 --- a/src/modules/schedule/settings.py +++ b/src/modules/schedule/settings.py @@ -11,6 +11,7 @@ from meta import conf from meta.errors import UserInputError from meta.sharding import THIS_SHARD from meta.logger import log_wrap +from wards import low_management_iward, high_management_iward from babel.translator import ctx_translator @@ -63,7 +64,8 @@ class ScheduleSettings(SettingGroup): class SessionLobby(ModelData, ChannelSetting): setting_id = 'session_lobby' _event = 'guildset_session_lobby' - _set_cmd = 'configure schedule' + _set_cmd = 'admin config schedule' + _write_ward = high_management_iward _display_name = _p('guildset:session_lobby', "session_lobby") _desc = _p( @@ -119,7 +121,8 @@ class ScheduleSettings(SettingGroup): @ScheduleConfig.register_model_setting class SessionRoom(ModelData, ChannelSetting): setting_id = 'session_room' - _set_cmd = 'configure schedule' + _set_cmd = 'admin config schedule' + _write_ward = high_management_iward _display_name = _p('guildset:session_room', "session_room") _desc = _p( @@ -163,6 +166,7 @@ class ScheduleSettings(SettingGroup): class SessionChannels(ListData, ChannelListSetting): setting_id = 'session_channels' + _write_ward = high_management_iward _display_name = _p('guildset:session_channels', "session_channels") _desc = _p( @@ -238,7 +242,8 @@ class ScheduleSettings(SettingGroup): @ScheduleConfig.register_model_setting class ScheduleCost(ModelData, CoinSetting): setting_id = 'schedule_cost' - _set_cmd = 'configure schedule' + _set_cmd = 'admin config schedule' + _write_ward = low_management_iward _display_name = _p('guildset:schedule_cost', "schedule_cost") _desc = _p( @@ -283,7 +288,8 @@ class ScheduleSettings(SettingGroup): @ScheduleConfig.register_model_setting class AttendanceReward(ModelData, CoinSetting): setting_id = 'attendance_reward' - _set_cmd = 'configure schedule' + _set_cmd = 'admin config schedule' + _write_ward = low_management_iward _display_name = _p('guildset:attendance_reward', "attendance_reward") _desc = _p( @@ -327,7 +333,8 @@ class ScheduleSettings(SettingGroup): @ScheduleConfig.register_model_setting class AttendanceBonus(ModelData, CoinSetting): setting_id = 'attendance_bonus' - _set_cmd = 'configure schedule' + _set_cmd = 'admin config schedule' + _write_ward = low_management_iward _display_name = _p('guildset:attendance_bonus', "group_attendance_bonus") _desc = _p( @@ -370,7 +377,8 @@ class ScheduleSettings(SettingGroup): @ScheduleConfig.register_model_setting class MinAttendance(ModelData, IntegerSetting): setting_id = 'min_attendance' - _set_cmd = 'configure schedule' + _set_cmd = 'admin config schedule' + _write_ward = low_management_iward _display_name = _p('guildset:min_attendance', "min_attendance") _desc = _p( @@ -437,8 +445,9 @@ class ScheduleSettings(SettingGroup): @ScheduleConfig.register_model_setting class BlacklistRole(ModelData, RoleSetting): setting_id = 'schedule_blacklist_role' - _set_cmd = 'configure schedule' + _set_cmd = 'admin config schedule' _event = 'guildset_schedule_blacklist_role' + _write_ward = high_management_iward _display_name = _p('guildset:schedule_blacklist_role', "schedule_blacklist_role") _desc = _p( @@ -495,7 +504,8 @@ class ScheduleSettings(SettingGroup): @ScheduleConfig.register_model_setting class BlacklistAfter(ModelData, IntegerSetting): setting_id = 'schedule_blacklist_after' - _set_cmd = 'configure schedule' + _set_cmd = 'admin config schedule' + _write_ward = low_management_iward _display_name = _p('guildset:schedule_blacklist_after', "schedule_blacklist_after") _desc = _p( diff --git a/src/modules/schedule/ui/scheduleui.py b/src/modules/schedule/ui/scheduleui.py index 4cf7c7d2..066de666 100644 --- a/src/modules/schedule/ui/scheduleui.py +++ b/src/modules/schedule/ui/scheduleui.py @@ -28,7 +28,21 @@ if TYPE_CHECKING: guide = _p( 'ui:schedule|about', - "Guide tips here TBD" + "**Do you think you can commit to a schedule and stick to it?**\n" + "**Schedule voice sessions here and get rewarded for keeping yourself accountable!**\n\n" + "Use the menu below to book timeslots using LionCoins. " + "If you are active (in the dedicated voice channels) during these times, " + "you will be rewarded, along with a large bonus if everyone who scheduled that slot " + "made it!\n" + "Beware though, if you fail to make it, all your booked sessions will be cancelled " + "with no refund! And if you keep failing to attend your scheduled sessions, " + "you may be forbidden from booking them in future.\n\n" + "When your scheduled session starts, you will recieve a ping from the schedule channel, " + "which will have more information about how to attend your session.\n" + "If you discover you can't make your scheduled session, please be responsible " + "and use this command to cancel or clear your schedule!\n\n" + "**Note:** *Make sure your timezone is set correctly (with `/my timezone`), " + "or the times I tell might not make sense!*" ) diff --git a/src/modules/schedule/ui/settingui.py b/src/modules/schedule/ui/settingui.py index 4ed4d4bb..4d1a185d 100644 --- a/src/modules/schedule/ui/settingui.py +++ b/src/modules/schedule/ui/settingui.py @@ -78,6 +78,7 @@ class ScheduleSettingUI(ConfigUI): # TODO: Setting value checks await selection.response.defer() setting = self.get_instance(ScheduleSettings.SessionLobby) + await setting.interaction_check(setting.parent_id, selection) setting.value = selected.values[0] if selected.values else None await setting.write() @@ -95,6 +96,7 @@ class ScheduleSettingUI(ConfigUI): async def room_menu(self, selection: discord.Interaction, selected: ChannelSelect): await selection.response.defer() setting = self.get_instance(ScheduleSettings.SessionRoom) + await setting.interaction_check(setting.parent_id, selection) setting.value = selected.values[0] if selected.values else None await setting.write() @@ -113,6 +115,7 @@ class ScheduleSettingUI(ConfigUI): # TODO: Consider XORing input await selection.response.defer() setting = self.get_instance(ScheduleSettings.SessionChannels) + await setting.interaction_check(setting.parent_id, selection) setting.value = selected.values await setting.write() @@ -158,6 +161,7 @@ class ScheduleSettingUI(ConfigUI): async def blacklist_role_menu(self, selection: discord.Interaction, selected: RoleSelect): await selection.response.defer() setting = self.get_instance(ScheduleSettings.BlacklistRole) + await setting.interaction_check(setting.parent_id, selection) setting.value = selected.values[0] if selected.values else None # TODO: Warning for insufficient permissions? await setting.write() @@ -227,7 +231,7 @@ class ScheduleSettingUI(ConfigUI): class ScheduleDashboard(DashboardSection): section_name = _p( 'dash:schedule|title', - "Scheduled Session Configuration ({commands[configure schedule]})" + "Scheduled Session Configuration ({commands[admin config schedule]})" ) _option_name = _p( "dash:schedule|dropdown|placeholder", @@ -248,7 +252,7 @@ class ScheduleDashboard(DashboardSection): page.add_field( name=t(_p( 'dash:schedule|section:schedule_channels|name', - "Scheduled Session Channels ({commands[configure schedule]})", + "Scheduled Session Channels ({commands[admin config schedule]})", )).format(commands=self.bot.core.mention_cache), value=table, inline=False @@ -258,7 +262,7 @@ class ScheduleDashboard(DashboardSection): page.add_field( name=t(_p( 'dash:schedule|section:schedule_rewards|name', - "Scheduled Session Rewards ({commands[configure schedule]})", + "Scheduled Session Rewards ({commands[admin config schedule]})", )).format(commands=self.bot.core.mention_cache), value=table, inline=False @@ -268,7 +272,7 @@ class ScheduleDashboard(DashboardSection): page.add_field( name=t(_p( 'dash:schedule|section:schedule_blacklist|name', - "Scheduled Session Blacklist ({commands[configure schedule]})", + "Scheduled Session Blacklist ({commands[admin config schedule]})", )).format(commands=self.bot.core.mention_cache), value=table, inline=False diff --git a/src/modules/skins/__init__.py b/src/modules/skins/__init__.py new file mode 100644 index 00000000..93f32c19 --- /dev/null +++ b/src/modules/skins/__init__.py @@ -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)) diff --git a/src/modules/skins/cog.py b/src/modules/skins/cog.py new file mode 100644 index 00000000..b142def1 --- /dev/null +++ b/src/modules/skins/cog.py @@ -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 diff --git a/src/modules/skins/data.py b/src/modules/skins/data.py new file mode 100644 index 00000000..742310a6 --- /dev/null +++ b/src/modules/skins/data.py @@ -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') diff --git a/src/modules/skins/editor/__init__.py b/src/modules/skins/editor/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/skins/editor/layout.py b/src/modules/skins/editor/layout.py new file mode 100644 index 00000000..ad0630ad --- /dev/null +++ b/src/modules/skins/editor/layout.py @@ -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 diff --git a/src/modules/skins/editor/pages/__init__.py b/src/modules/skins/editor/pages/__init__.py new file mode 100644 index 00000000..caa2cf21 --- /dev/null +++ b/src/modules/skins/editor/pages/__init__.py @@ -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, +] diff --git a/src/modules/skins/editor/pages/leaderboard.py b/src/modules/skins/editor/pages/leaderboard.py new file mode 100644 index 00000000..8ce00056 --- /dev/null +++ b/src/modules/skins/editor/pages/leaderboard.py @@ -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 +] + diff --git a/src/modules/skins/editor/pages/monthly.py b/src/modules/skins/editor/pages/monthly.py new file mode 100644 index 00000000..20693925 --- /dev/null +++ b/src/modules/skins/editor/pages/monthly.py @@ -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 +] + diff --git a/src/modules/skins/editor/pages/monthly_goals.py b/src/modules/skins/editor/pages/monthly_goals.py new file mode 100644 index 00000000..075bce72 --- /dev/null +++ b/src/modules/skins/editor/pages/monthly_goals.py @@ -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 +] + diff --git a/src/modules/skins/editor/pages/profile.py b/src/modules/skins/editor/pages/profile.py new file mode 100644 index 00000000..d8e07bcf --- /dev/null +++ b/src/modules/skins/editor/pages/profile.py @@ -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, +] + diff --git a/src/modules/skins/editor/pages/stats.py b/src/modules/skins/editor/pages/stats.py new file mode 100644 index 00000000..dd9f86c1 --- /dev/null +++ b/src/modules/skins/editor/pages/stats.py @@ -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] + diff --git a/src/modules/skins/editor/pages/summary.py b/src/modules/skins/editor/pages/summary.py new file mode 100644 index 00000000..8f31986d --- /dev/null +++ b/src/modules/skins/editor/pages/summary.py @@ -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] + diff --git a/src/modules/skins/editor/pages/weekly.py b/src/modules/skins/editor/pages/weekly.py new file mode 100644 index 00000000..e4eff376 --- /dev/null +++ b/src/modules/skins/editor/pages/weekly.py @@ -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, +] + diff --git a/src/modules/skins/editor/pages/weekly_goals.py b/src/modules/skins/editor/pages/weekly_goals.py new file mode 100644 index 00000000..f81b449d --- /dev/null +++ b/src/modules/skins/editor/pages/weekly_goals.py @@ -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 +] + diff --git a/src/modules/skins/editor/skineditor.py b/src/modules/skins/editor/skineditor.py new file mode 100644 index 00000000..f05e66d0 --- /dev/null +++ b/src/modules/skins/editor/skineditor.py @@ -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 diff --git a/src/modules/skins/editor/skinsetting.py b/src/modules/skins/editor/skinsetting.py new file mode 100644 index 00000000..1ddbdfc6 --- /dev/null +++ b/src/modules/skins/editor/skinsetting.py @@ -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}`" diff --git a/src/modules/skins/settings.py b/src/modules/skins/settings.py new file mode 100644 index 00000000..a700076c --- /dev/null +++ b/src/modules/skins/settings.py @@ -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 + diff --git a/src/modules/skins/settingui.py b/src/modules/skins/settingui.py new file mode 100644 index 00000000..705db102 --- /dev/null +++ b/src/modules/skins/settingui.py @@ -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 + ] diff --git a/src/modules/skins/skinlib.py b/src/modules/skins/skinlib.py new file mode 100644 index 00000000..2ce57141 --- /dev/null +++ b/src/modules/skins/skinlib.py @@ -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 diff --git a/src/modules/skins/userskinui.py b/src/modules/skins/userskinui.py new file mode 100644 index 00000000..210f67f2 --- /dev/null +++ b/src/modules/skins/userskinui.py @@ -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" 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) diff --git a/src/modules/sponsors/__init__.py b/src/modules/sponsors/__init__.py new file mode 100644 index 00000000..78988da2 --- /dev/null +++ b/src/modules/sponsors/__init__.py @@ -0,0 +1,10 @@ +import logging +from babel.translator import LocalBabel + +babel = LocalBabel('sponsors') +logger = logging.getLogger(__name__) + + +async def setup(bot): + from .cog import SponsorCog + await bot.add_cog(SponsorCog(bot)) diff --git a/src/modules/sponsors/cog.py b/src/modules/sponsors/cog.py new file mode 100644 index 00000000..267c7d63 --- /dev/null +++ b/src/modules/sponsors/cog.py @@ -0,0 +1,130 @@ +from typing import Optional +import asyncio + +import discord +from discord.ext import commands as cmds +import discord.app_commands as appcmds + +from meta import LionCog, LionBot, LionContext +from wards import sys_admin_ward + +from . import logger, babel +from .data import SponsorData +from .settings import SponsorSettings +from .settingui import SponsorUI + +_p = babel._p + + +class SponsorCog(LionCog): + def __init__(self, bot: LionBot): + self.bot = bot + self.data: SponsorData = bot.db.load_registry(SponsorData()) + self.settings = SponsorSettings + + self.whitelisted = self.settings.Whitelist._cache + + 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.settings) + self.crossload_group(self.leo_group, leo_setting_cog.leo_group) + + async def do_sponsor_prompt(self, interaction: discord.Interaction): + """ + Send the sponsor prompt as a followup to this interaction, if applicable. + """ + if not interaction.is_expired(): + # TODO: caching + if interaction.guild: + whitelist = (await self.settings.Whitelist.get(self.bot.appname)).value + 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 + if value: + args = setting.value_to_args(self.bot.appname, value) + followup = interaction.followup + await followup.send(**args.send_args, ephemeral=True) + + @cmds.hybrid_command( + name=_p('cmd:sponsors', "sponsors"), + description=_p( + 'cmd:sponsors|desc', + "Check out our wonderful partners!" + ) + ) + async def sponsor_cmd(self, ctx: LionContext): + """ + Display the sponsors message, if set. + """ + if ctx.interaction: + await ctx.interaction.response.defer(thinking=True, ephemeral=True) + + sponsor = await self.settings.SponsorMessage.get(self.bot.appname) + value = sponsor.value + if value: + args = sponsor.value_to_args(self.bot.appname, value) + await ctx.reply(**args.send_args) + else: + await ctx.reply( + "Coming Soon!" + ) + + @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_sponsors', "sponsors" + ), + description=_p( + 'cmd:leo_sponsors|desc', + "Configure the sponsor text and whitelist." + ) + ) + @appcmds.rename( + sponsor_prompt=SponsorSettings.SponsorPrompt._display_name, + sponsor_message=SponsorSettings.SponsorMessage._display_name, + ) + @appcmds.describe( + sponsor_prompt=SponsorSettings.SponsorPrompt._desc, + sponsor_message=SponsorSettings.SponsorMessage._desc, + ) + @sys_admin_ward + async def leo_sponsors_cmd(self, ctx: LionContext, + sponsor_prompt: Optional[discord.Attachment] = None, + sponsor_message: Optional[discord.Attachment] = None, + ): + """ + Open the configuration UI for sponsors, and optionally set the prompt and message. + """ + if not ctx.interaction: + return + + await ctx.interaction.response.defer(thinking=True) + modified = [] + + if sponsor_prompt is not None: + setting = self.settings.SponsorPrompt + content = await setting.download_attachment(sponsor_prompt) + instance = await setting.from_string(self.bot.appname, content) + modified.append(instance) + + if sponsor_message is not None: + setting = self.settings.SponsorMessage + content = await setting.download_attachment(sponsor_message) + instance = await setting.from_string(self.bot.appname, content) + modified.append(instance) + + for instance in modified: + await instance.write() + + ui = SponsorUI(self.bot, self.bot.appname, ctx.channel.id) + await ui.run(ctx.interaction) + await ui.wait() diff --git a/src/modules/sponsors/data.py b/src/modules/sponsors/data.py new file mode 100644 index 00000000..5fa9b683 --- /dev/null +++ b/src/modules/sponsors/data.py @@ -0,0 +1,5 @@ +from data import Registry, Table + + +class SponsorData(Registry): + sponsor_whitelist = Table('sponsor_guild_whitelist') diff --git a/src/modules/sponsors/settings.py b/src/modules/sponsors/settings.py new file mode 100644 index 00000000..3b57b77f --- /dev/null +++ b/src/modules/sponsors/settings.py @@ -0,0 +1,87 @@ +from settings.data import ListData, ModelData +from settings.groups import SettingGroup +from settings.setting_types import GuildIDListSetting + +from core.setting_types import MessageSetting +from core.data import CoreData +from wards import sys_admin_iward +from . import babel +from .data import SponsorData + +_p = babel._p + + +class SponsorSettings(SettingGroup): + class Whitelist(ListData, GuildIDListSetting): + setting_id = 'sponsor_whitelist' + _write_ward = sys_admin_iward + + _display_name = _p( + 'botset:sponsor_whitelist', "sponsor_whitelist" + ) + _desc = _p( + 'botset:sponsor_whitelist|desc', + "List of guildids where the sponsor prompt is not shown." + ) + _long_desc = _p( + 'botset:sponsor_whitelist|long_desc', + "The sponsor prompt will not appear in the set guilds." + ) + _accepts = _p( + 'botset:sponsor_whitelist|accetps', + "Comma separated list of guildids." + ) + + _table_interface = SponsorData.sponsor_whitelist + _id_column = 'appid' + _data_column = 'guildid' + _order_column = 'guildid' + + class SponsorPrompt(ModelData, MessageSetting): + setting_id = 'sponsor_prompt' + _set_cmd = 'leo sponsors' + _write_ward = sys_admin_iward + + _display_name = _p( + 'botset:sponsor_prompt', "sponsor_prompt" + ) + _desc = _p( + 'botset:sponsor_prompt|desc', + "Message to add underneath core commands." + ) + _long_desc = _p( + 'botset:sponsor_prompt|long_desc', + "Content of the message to send after core commands such as stats," + " reminding users to check the sponsors command." + ) + + _model = CoreData.BotConfig + _column = CoreData.BotConfig.sponsor_prompt.name + + async def editor_callback(self, editor_data): + self.value = editor_data + await self.write() + + class SponsorMessage(ModelData, MessageSetting): + setting_id = 'sponsor_message' + _set_cmd = 'leo sponsors' + _write_ward = sys_admin_iward + + _display_name = _p( + 'botset:sponsor_message', "sponsor_message" + ) + _desc = _p( + 'botset:sponsor_message|desc', + "Message to send in response to /sponsors command." + ) + _long_desc = _p( + 'botset:sponsor_message|long_desc', + "Content of the message to send when a user runs the `/sponsors` command." + ) + + _model = CoreData.BotConfig + _column = CoreData.BotConfig.sponsor_message.name + + async def editor_callback(self, editor_data): + self.value = editor_data + await self.write() diff --git a/src/modules/sponsors/settingui.py b/src/modules/sponsors/settingui.py new file mode 100644 index 00000000..738c64ca --- /dev/null +++ b/src/modules/sponsors/settingui.py @@ -0,0 +1,122 @@ +import asyncio + +import discord +from discord.ui.button import button, Button, ButtonStyle + +from meta import LionBot + +from utils.ui import ConfigUI +from utils.lib import MessageArgs +from utils.ui.msgeditor import MsgEditor + +from .settings import SponsorSettings as Settings +from . import babel, logger + +_p = babel._p + + +class SponsorUI(ConfigUI): + setting_classes = ( + Settings.SponsorPrompt, + Settings.SponsorMessage, + Settings.Whitelist, + ) + + def __init__(self, bot: LionBot, appname: str, channelid: int, **kwargs): + self.settings = bot.get_cog('SponsorCog').settings + super().__init__(bot, appname, channelid, **kwargs) + + # ----- UI Components ----- + @button( + label="SPONSOR_PROMPT_BUTTON_PLACEHOLDER", + style=ButtonStyle.blurple + ) + async def sponsor_prompt_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer(thinking=True, ephemeral=True) + setting = self.get_instance(Settings.SponsorPrompt) + + value = setting.value + if value is None: + value = {'content': "Empty"} + + editor = MsgEditor( + self.bot, + value, + callback=setting.editor_callback, + callerid=press.user.id, + ) + self._slaves.append(editor) + await editor.run(press) + + async def sponsor_prompt_button_refresh(self): + button = self.sponsor_prompt_button + t = self.bot.translator.t + button.label = t(_p( + 'ui:sponsors|button:sponsor_prompt|label', + "Sponsor Prompt" + )) + + @button( + label="SPONSOR_MESSAGE_BUTTON_PLACEHOLDER", + style=ButtonStyle.blurple + ) + async def sponsor_message_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer(thinking=True, ephemeral=True) + setting = self.get_instance(Settings.SponsorMessage) + + value = setting.value + if value is None: + value = {'content': "Empty"} + + editor = MsgEditor( + self.bot, + value, + callback=setting.editor_callback, + callerid=press.user.id, + ) + self._slaves.append(editor) + await editor.run(press) + + async def sponsor_message_button_refresh(self): + button = self.sponsor_message_button + t = self.bot.translator.t + button.label = t(_p( + 'ui:sponsors|button:sponsor_message|label', + "Sponsor Message" + )) + # ----- UI Flow ----- + async def make_message(self) -> MessageArgs: + t = self.bot.translator.t + title = t(_p( + 'ui:sponsors|embed|title', + "Leo Sponsor Panel" + )) + 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 reload(self): + self.instances = [ + await setting.get(self.bot.appname) + for setting in self.setting_classes + ] + + async def refresh_components(self): + to_refresh = ( + self.edit_button_refresh(), + self.close_button_refresh(), + self.reset_button_refresh(), + self.sponsor_message_button_refresh(), + self.sponsor_prompt_button_refresh(), + ) + await asyncio.gather(*to_refresh) + + self.set_layout( + (self.sponsor_prompt_button, self.sponsor_message_button, + self.edit_button, self.reset_button, self.close_button) + ) diff --git a/src/modules/statistics/cog.py b/src/modules/statistics/cog.py index fa3d923c..c40c7872 100644 --- a/src/modules/statistics/cog.py +++ b/src/modules/statistics/cog.py @@ -12,7 +12,7 @@ from core.lion_guild import VoiceMode from utils.lib import error_embed from utils.ui import LeoUI, AButton, utc_now from gui.base import CardMode -from wards import low_management_ward +from wards import high_management_ward from . import babel from .data import StatsData @@ -41,7 +41,7 @@ class StatsCog(LionCog): self.bot.core.guild_config.register_setting(self.settings.UnrankedRoles) configcog = self.bot.get_cog('ConfigCog') - self.crossload_group(self.configure_group, configcog.configure_group) + self.crossload_group(self.configure_group, configcog.admin_config_group) @cmds.hybrid_command( name=_p('cmd:me', "me"), @@ -55,6 +55,8 @@ class StatsCog(LionCog): await ctx.interaction.response.defer(thinking=True) ui = ProfileUI(self.bot, ctx.author, ctx.guild) await ui.run(ctx.interaction) + if sponsors := self.bot.get_cog('SponsorCog'): + await sponsors.do_sponsor_prompt(ctx.interaction) await ui.wait() @cmds.hybrid_command( @@ -101,6 +103,9 @@ class StatsCog(LionCog): file = discord.File(profile_data, 'profile.png') await ctx.reply(file=file) + if sponsors := self.bot.get_cog('SponsorCog'): + await sponsors.do_sponsor_prompt(ctx.interaction) + @cmds.hybrid_command( name=_p('cmd:stats', "stats"), description=_p( @@ -116,6 +121,10 @@ class StatsCog(LionCog): await ctx.interaction.response.defer(thinking=True) ui = WeeklyMonthlyUI(self.bot, ctx.author, ctx.guild) await ui.run(ctx.interaction) + + if sponsors := self.bot.get_cog('SponsorCog'): + await sponsors.do_sponsor_prompt(ctx.interaction) + await ui.wait() @cmds.hybrid_command( @@ -151,6 +160,10 @@ class StatsCog(LionCog): await ctx.interaction.response.defer(thinking=True) ui = LeaderboardUI(self.bot, ctx.author, ctx.guild) await ui.run(ctx.interaction) + + if sponsors := self.bot.get_cog('SponsorCog'): + await sponsors.do_sponsor_prompt(ctx.interaction) + await ui.wait() @cmds.hybrid_command( @@ -204,8 +217,7 @@ class StatsCog(LionCog): "Time from which to start counting activity for rank badges and season leaderboards. (YYYY-MM-DD)" ) ) - @appcmds.default_permissions(manage_guild=True) - @low_management_ward + @high_management_ward async def configure_statistics_cmd(self, ctx: LionContext, season_start: Optional[str] = None): t = self.bot.translator.t diff --git a/src/modules/statistics/graphics/goals.py b/src/modules/statistics/graphics/goals.py index 86181bed..6dfe94cd 100644 --- a/src/modules/statistics/graphics/goals.py +++ b/src/modules/statistics/graphics/goals.py @@ -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 diff --git a/src/modules/statistics/graphics/leaderboard.py b/src/modules/statistics/graphics/leaderboard.py index 36632634..310cfc93 100644 --- a/src/modules/statistics/graphics/leaderboard.py +++ b/src/modules/statistics/graphics/leaderboard.py @@ -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 diff --git a/src/modules/statistics/graphics/monthly.py b/src/modules/statistics/graphics/monthly.py index c6855a2a..fb47e97e 100644 --- a/src/modules/statistics/graphics/monthly.py +++ b/src/modules/statistics/graphics/monthly.py @@ -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 diff --git a/src/modules/statistics/graphics/profile.py b/src/modules/statistics/graphics/profile.py index 38fac587..fc70a68c 100644 --- a/src/modules/statistics/graphics/profile.py +++ b/src/modules/statistics/graphics/profile.py @@ -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 diff --git a/src/modules/statistics/graphics/stats.py b/src/modules/statistics/graphics/stats.py index 5a093db1..aa4eacaa 100644 --- a/src/modules/statistics/graphics/stats.py +++ b/src/modules/statistics/graphics/stats.py @@ -70,7 +70,7 @@ async def get_stats_card(bot: LionBot, userid: int, guildid: int, mode: CardMode refkey = (guildid, userid) else: model = data.UserExp - msg_since = msgmodel.member_messages_between + msg_since = msgmodel.user_messages_since refkey = (userid,) ref_since = model.xp_since ref_between = model.xp_between @@ -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 diff --git a/src/modules/statistics/graphics/weekly.py b/src/modules/statistics/graphics/weekly.py index 74b8b103..00f43358 100644 --- a/src/modules/statistics/graphics/weekly.py +++ b/src/modules/statistics/graphics/weekly.py @@ -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 diff --git a/src/modules/statistics/settings.py b/src/modules/statistics/settings.py index 375c577f..37a4ac83 100644 --- a/src/modules/statistics/settings.py +++ b/src/modules/statistics/settings.py @@ -21,6 +21,7 @@ from utils.lib import MessageArgs from core.data import CoreData from core.lion_guild import VoiceMode from babel.translator import ctx_translator +from wards import low_management_iward, high_management_iward from . import babel from .data import StatsData, StatisticType @@ -83,7 +84,8 @@ class StatisticsSettings(SettingGroup): Time is assumed to be in set guild timezone (although supports +00 syntax) """ setting_id = 'season_start' - _set_cmd = 'configure statistics' + _set_cmd = 'admin config statistics' + _write_ward = high_management_iward _display_name = _p('guildset:season_start', "season_start") _desc = _p( @@ -155,6 +157,7 @@ class StatisticsSettings(SettingGroup): List of roles not displayed on the leaderboard """ setting_id = 'unranked_roles' + _write_ward = high_management_iward _display_name = _p('guildset:unranked_roles', "unranked_roles") _desc = _p( @@ -211,6 +214,7 @@ class StatisticsSettings(SettingGroup): Default is determined by current guild mode """ setting_id = 'visible_stats' + _write_ward = high_management_iward _setting = StatTypeSetting @@ -263,6 +267,7 @@ class StatisticsSettings(SettingGroup): Which of the three stats to display by default """ setting_id = 'default_stat' + _write_ward = high_management_iward _display_name = _p('guildset:default_stat', "default_stat") _desc = _p( @@ -294,6 +299,7 @@ class StatisticsConfigUI(ConfigUI): """ await selection.response.defer(thinking=True) setting = self.instances[1] + await setting.interaction_check(setting.parent_id, selection) setting.value = selected.values await setting.write() # Don't need to refresh due to instance hooks @@ -314,6 +320,7 @@ class StatisticsConfigUI(ConfigUI): """ await selection.response.defer(thinking=True) setting = self.instances[2] + await setting.interaction_check(setting.parent_id, selection) data = [StatisticType((value,)) for value in selected.values] setting.data = data await setting.write() @@ -405,7 +412,7 @@ class StatisticsConfigUI(ConfigUI): class StatisticsDashboard(DashboardSection): section_name = _p( 'dash:stats|title', - "Activity Statistics Configuration ({commands[configure statistics]})" + "Activity Statistics Configuration ({commands[admin config statistics]})" ) _option_name = _p( "dash:stats|dropdown|placeholder", diff --git a/src/modules/statistics/ui/leaderboard.py b/src/modules/statistics/ui/leaderboard.py index 4e017a82..48c27005 100644 --- a/src/modules/statistics/ui/leaderboard.py +++ b/src/modules/statistics/ui/leaderboard.py @@ -537,6 +537,11 @@ 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): """ diff --git a/src/modules/statistics/ui/profile.py b/src/modules/statistics/ui/profile.py index 5eafa727..c9c39479 100644 --- a/src/modules/statistics/ui/profile.py +++ b/src/modules/statistics/ui/profile.py @@ -297,6 +297,12 @@ class ProfileUI(StatsUI): (self.stats_button, self.edit_button, self.close_button) ] + 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): """ Create and render the profile card. diff --git a/src/modules/statistics/ui/weeklymonthly.py b/src/modules/statistics/ui/weeklymonthly.py index fd295f82..960bf119 100644 --- a/src/modules/statistics/ui/weeklymonthly.py +++ b/src/modules/statistics/ui/weeklymonthly.py @@ -750,6 +750,13 @@ class WeeklyMonthlyUI(StatsUI): (self.type_menu,), (self.edit_button, self.select_button, self.global_button, self.close_button) ] + + 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: await self.period_menu_refresh() self._layout.append((self.period_menu,)) diff --git a/src/modules/sysadmin/exec_cog.py b/src/modules/sysadmin/exec_cog.py index 13acfa4d..ff0b8cbb 100644 --- a/src/modules/sysadmin/exec_cog.py +++ b/src/modules/sysadmin/exec_cog.py @@ -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'), diff --git a/src/modules/tasklist/cog.py b/src/modules/tasklist/cog.py index 347e45f0..739a226a 100644 --- a/src/modules/tasklist/cog.py +++ b/src/modules/tasklist/cog.py @@ -139,7 +139,7 @@ class TasklistCog(LionCog): self.bot.add_view(TasklistCaller(self.bot)) configcog = self.bot.get_cog('ConfigCog') - self.crossload_group(self.configure_group, configcog.configure_group) + self.crossload_group(self.configure_group, configcog.config_group) @LionCog.listener('on_tasks_completed') @log_wrap(action="reward tasks completed") @@ -261,6 +261,7 @@ class TasklistCog(LionCog): "Open your tasklist." ) ) + @appcmds.guild_only async def tasklist_cmd(self, ctx: LionContext): if not ctx.interaction: return @@ -270,6 +271,7 @@ class TasklistCog(LionCog): name=_p('group:tasks', "tasks"), description=_p('group:tasks|desc', "Base command group for tasklist commands.") ) + @appcmds.guild_only async def tasklist_group(self, ctx: LionContext): raise NotImplementedError @@ -984,7 +986,6 @@ class TasklistCog(LionCog): reward=TasklistSettings.task_reward._desc, reward_limit=TasklistSettings.task_reward_limit._desc ) - @appcmds.default_permissions(manage_guild=True) @low_management_ward async def configure_tasklist_cmd(self, ctx: LionContext, reward: Optional[int] = None, diff --git a/src/modules/tasklist/settings.py b/src/modules/tasklist/settings.py index 703416e0..aee300c7 100644 --- a/src/modules/tasklist/settings.py +++ b/src/modules/tasklist/settings.py @@ -13,6 +13,7 @@ from utils.lib import tabulate from utils.ui import LeoUI, FastModal, error_handler_for, ModalRetryUI, DashboardSection from core.data import CoreData from babel.translator import ctx_translator +from wards import low_management_iward, high_management_iward from . import babel from .data import TasklistData @@ -28,7 +29,8 @@ class TasklistSettings(SettingGroup): Exposed via `/configure tasklist`, and the standard configuration interface. """ setting_id = 'task_reward' - _set_cmd = 'configure tasklist' + _set_cmd = 'config tasklist' + _write_ward = low_management_iward _display_name = _p('guildset:task_reward', "task_reward") _desc = _p( @@ -68,7 +70,8 @@ class TasklistSettings(SettingGroup): class task_reward_limit(ModelData, IntegerSetting): setting_id = 'task_reward_limit' - _set_cmd = 'configure tasklist' + _set_cmd = 'config tasklist' + _write_ward = low_management_iward _display_name = _p('guildset:task_reward_limit', "task_reward_limit") _desc = _p( @@ -109,6 +112,7 @@ class TasklistSettings(SettingGroup): class tasklist_channels(ListData, ChannelListSetting): setting_id = 'tasklist_channels' + _write_ward = low_management_iward _display_name = _p('guildset:tasklist_channels', "tasklist_channels") _desc = _p( @@ -317,7 +321,7 @@ class TasklistConfigUI(LeoUI): class TasklistDashboard(DashboardSection): - section_name = _p('dash:tasklist|name', "Tasklist Configuration ({commands[configure tasklist]})") + section_name = _p('dash:tasklist|name', "Tasklist Configuration ({commands[config tasklist]})") _option_name = _p( "dash:tasklist|dropdown|placeholder", "Tasklist Options Panel" diff --git a/src/modules/topgg/__init__.py b/src/modules/topgg/__init__.py new file mode 100644 index 00000000..92681092 --- /dev/null +++ b/src/modules/topgg/__init__.py @@ -0,0 +1,10 @@ +import logging +from babel.translator import LocalBabel + +babel = LocalBabel('topgg') +logger = logging.getLogger(__name__) + + +async def setup(bot): + from .cog import TopggCog + await bot.add_cog(TopggCog(bot)) diff --git a/src/modules/topgg/cog.py b/src/modules/topgg/cog.py new file mode 100644 index 00000000..9d6847ed --- /dev/null +++ b/src/modules/topgg/cog.py @@ -0,0 +1,135 @@ +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 topgg import WebhookManager +from data.queries import ORDER + +from meta import LionCog, LionBot, LionContext +from meta.logger import log_wrap +from wards import sys_admin_ward +from utils.lib import utc_now +from babel.translator import ctx_locale + +from . import logger, babel +from .data import TopggData + +_p = babel._p + +topgg_upvote_link = 'https://top.gg/bot/889078613817831495/vote' + + +class TopggCog(LionCog): + def __init__(self, bot: LionBot): + self.bot = bot + self.data: TopggData = bot.db.load_registry(TopggData()) + + self.topgg_webhook: Optional[WebhookManager] = None + + async def cog_load(self): + await self.data.init() + + tgg_config = self.bot.config.topgg + if tgg_config.getboolean('enabled', False): + economy = self.bot.get_cog('Economy') + economy.register_economy_bonus(self.voting_bonus, name='voting') + + if self.bot.shard_id != 0: + logger.debug( + f"Not initialising topgg executor in shard {self.bot.shard_id}" + ) + else: + self.topgg_webhook = WebhookManager(self.bot).dbl_webhook( + route=tgg_config.get('route'), + auth_key=tgg_config.get('auth'), + ) + self.topgg_webhook.run(tgg_config.getint('port')) + + logger.info( + "Topgg webhook registered." + ) + else: + logger.info( + "Topgg disabled via config, not initialising module." + ) + + @LionCog.listener('on_dbl_vote') + @log_wrap(action="Handle DBL Vote") + async def handle_dbl_vote(self, data): + logger.info(f"Recieved TopGG vote: {data}") + userid = data['user'] + + await self.data.TopGG.create( + userid=userid, + boostedtimestamp=utc_now() + ) + await self._send_thanks_dm(userid) + + async def voting_bonus(self, userid): + # Provides 1.25 multiplicative bonus if they have voted within 12h + if await self.check_voted_recently(userid): + return 1.25 + else: + return 1 + + async def check_voted_recently(self, userid): + records = await self.data.TopGG.fetch_where( + userid=userid + ).order_by('boostedtimestamp', ORDER.DESC).limit(1) + + return records and (utc_now() - records[0].boostedtimestamp).total_seconds() < 3600 * 12 + + def vote_button(self): + t = self.bot.translator.t + + button = Button( + style=ButtonStyle.link, + label=t(_p( + 'button:vote|label', + "Vote for me!" + )), + emoji=self.bot.config.emojis.coin, + url=topgg_upvote_link, + ) + return button + + async def _send_thanks_dm(self, userid: int): + user = self.bot.get_user(userid) + if user is None: + try: + user = await self.bot.fetch_user(userid) + except discord.HTTPException: + logger.warning( + f"Could not find voting user to send thanks." + ) + return + + t = self.bot.translator.t + luser = await self.bot.core.lions.fetch_user(userid) + locale = await self.bot.get_cog('BabelCog').get_user_locale(userid) + ctx_locale.set(locale) + + embed = discord.Embed( + colour=discord.Colour.brand_green(), + title=t(_p( + 'embed:voting_thanks|title', + "Thank you for supporting me on Top.gg! {yay}" + )).format(yay=self.bot.config.emojis.lionyay), + description=t(_p( + 'embed:voting_thanks|desc', + "Thank you for supporting us, enjoy your LionCoins boost!" + )) + + ).set_image( + url="https://cdn.discordapp.com/attachments/908283085999706153/932737228440993822/lion-yay.png" + ) + + try: + await user.send(embed=embed) + except discord.HTTPException: + logger.warning( + f"Could not send voting thanks to user ." + ) diff --git a/src/modules/topgg/data.py b/src/modules/topgg/data.py new file mode 100644 index 00000000..cb3219f2 --- /dev/null +++ b/src/modules/topgg/data.py @@ -0,0 +1,13 @@ +from data import Registry, Table, RowModel +from data.columns import Integer, Timestamp + + +class TopggData(Registry): + class TopGG(RowModel): + _tablename_ = 'topgg' + + voteid = Integer(primary=True) + userid = Integer() + boostedtimestamp = Timestamp() + + guild_whitelist = Table('topgg_guild_whitelist') diff --git a/src/modules/video_channels/cog.py b/src/modules/video_channels/cog.py index 7e6338ac..ec84bce8 100644 --- a/src/modules/video_channels/cog.py +++ b/src/modules/video_channels/cog.py @@ -57,7 +57,7 @@ class VideoCog(LionCog): "Could not load ConfigCog. VideoCog configuration will not crossload." ) else: - self.crossload_group(self.configure_group, configcog.configure_group) + self.crossload_group(self.configure_group, configcog.admin_config_group) if self.bot.is_ready(): await self.initialise() @@ -522,7 +522,7 @@ class VideoCog(LionCog): video_blacklist_durations=VideoSettings.VideoBlacklistDurations._desc, video_grace_period=VideoSettings.VideoGracePeriod._desc, ) - @low_management_ward + @high_management_ward async def configure_video(self, ctx: LionContext, video_blacklist: Optional[discord.Role] = None, video_blacklist_durations: Optional[str] = None, @@ -572,4 +572,3 @@ class VideoCog(LionCog): ui = VideoSettingUI(self.bot, ctx.guild.id, ctx.channel.id) await ui.run(ctx.interaction) await ui.wait() - diff --git a/src/modules/video_channels/settings.py b/src/modules/video_channels/settings.py index 82fe58c4..2d7c4e35 100644 --- a/src/modules/video_channels/settings.py +++ b/src/modules/video_channels/settings.py @@ -14,6 +14,7 @@ from meta.sharding import THIS_SHARD from meta.logger import log_wrap from core.data import CoreData from babel.translator import ctx_translator +from wards import low_management_iward, high_management_iward from . import babel, logger from .data import VideoData @@ -25,6 +26,7 @@ class VideoSettings(SettingGroup): class VideoChannels(ListData, ChannelListSetting): setting_id = "video_channels" _event = 'guildset_video_channels' + _write_ward = high_management_iward _display_name = _p('guildset:video_channels', "video_channels") _desc = _p( @@ -101,6 +103,7 @@ class VideoSettings(SettingGroup): class VideoBlacklist(ModelData, RoleSetting): setting_id = "video_blacklist" _event = 'guildset_video_blacklist' + _write_ward = high_management_iward _display_name = _p('guildset:video_blacklist', "video_blacklist") _desc = _p( @@ -158,6 +161,7 @@ class VideoSettings(SettingGroup): class VideoBlacklistDurations(ListData, ListSetting, InteractiveSetting): setting_id = 'video_durations' _setting = DurationSetting + _write_ward = high_management_iward _display_name = _p('guildset:video_durations', "video_blacklist_durations") _desc = _p( @@ -217,6 +221,7 @@ class VideoSettings(SettingGroup): class VideoGracePeriod(ModelData, DurationSetting): setting_id = "video_grace_period" _event = 'guildset_video_grace_period' + _write_ward = high_management_iward _display_name = _p('guildset:video_grace_period', "video_grace_period") _desc = _p( @@ -252,6 +257,7 @@ class VideoSettings(SettingGroup): class VideoExempt(ListData, RoleListSetting): setting_id = "video_exempt" _event = 'guildset_video_exempt' + _write_ward = high_management_iward _display_name = _p('guildset:video_exempt', "video_exempt") _desc = _p( diff --git a/src/modules/video_channels/settingui.py b/src/modules/video_channels/settingui.py index 399fe72f..608977e8 100644 --- a/src/modules/video_channels/settingui.py +++ b/src/modules/video_channels/settingui.py @@ -45,6 +45,7 @@ class VideoSettingUI(ConfigUI): await selection.response.defer(thinking=True, ephemeral=True) setting = self.get_instance(VideoSettings.VideoChannels) + await setting.interaction_check(setting.parent_id, selection) setting.value = selected.values await setting.write() await selection.delete_original_response() @@ -70,6 +71,7 @@ class VideoSettingUI(ConfigUI): await selection.response.defer(thinking=True, ephemeral=True) setting = self.get_instance(VideoSettings.VideoExempt) + await setting.interaction_check(setting.parent_id, selection) setting.value = selected.values await setting.write() await selection.delete_original_response() @@ -95,6 +97,7 @@ class VideoSettingUI(ConfigUI): await selection.response.defer(thinking=True, ephemeral=True) setting = self.get_instance(VideoSettings.VideoBlacklist) + await setting.interaction_check(setting.parent_id, selection) setting.value = selected.values[0] if selected.values else None if setting.value: await equippable_role(self.bot, setting.value, selection.user) @@ -153,7 +156,7 @@ class VideoSettingUI(ConfigUI): class VideoDashboard(DashboardSection): section_name = _p( "dash:video|title", - "Video Channel Settings ({commands[configure video_channels]})" + "Video Channel Settings ({commands[admin config video_channels]})" ) _option_name = _p( "dash:video|option|name", diff --git a/src/settings/setting_types.py b/src/settings/setting_types.py index 71239cfc..4cf41776 100644 --- a/src/settings/setting_types.py +++ b/src/settings/setting_types.py @@ -1381,7 +1381,7 @@ class StringListSetting(InteractiveSetting, ListSetting): _setting = StringSetting -class GuildIDListSetting(InteractiveSetting, ListSetting): +class GuildIDListSetting(ListSetting, InteractiveSetting): """ List of guildids. """ diff --git a/src/settings/ui.py b/src/settings/ui.py index 5fff6e32..b2a263ea 100644 --- a/src/settings/ui.py +++ b/src/settings/ui.py @@ -7,6 +7,7 @@ from discord import ui from discord.ui.button import ButtonStyle, Button, button from discord.ui.modal import Modal from discord.ui.text_input import TextInput +from meta.errors import UserInputError from utils.lib import tabulate, recover_context from utils.ui import FastModal @@ -192,6 +193,9 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]): # Event handlers should be of the form Callable[ParentID, SettingData] _event: Optional[str] = None + # Interaction ward that should be validated via interaction_check + _write_ward: Optional[Callable[[discord.Interaction], Coroutine[Any, Any, bool]]] = None + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -488,6 +492,16 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]): """ pass + @classmethod + async def interaction_check(cls, parent_id, interaction: discord.Interaction, **kwargs): + if cls._write_ward is not None and not await cls._write_ward(interaction): + # TODO: Combine the check system so we can do customised errors here + t = ctx_translator.get().t + raise UserInputError(t(_p( + 'setting|interaction_check|error', + "You do not have sufficient permissions to do this!" + ))) + """ command callback for set command? diff --git a/src/tracking/text/cog.py b/src/tracking/text/cog.py index 1d48a2ee..8da98811 100644 --- a/src/tracking/text/cog.py +++ b/src/tracking/text/cog.py @@ -16,7 +16,7 @@ from meta.app import appname from meta.monitor import ComponentMonitor, ComponentStatus, StatusLevel from utils.lib import utc_now, error_embed -from wards import low_management_ward, sys_admin_ward +from wards import low_management_ward, sys_admin_ward, low_management_iward from . import babel, logger from .data import TextTrackerData @@ -116,7 +116,7 @@ class TextTrackerCog(LionCog): "Attempting to load the TextTrackerCog before ConfigCog! Failed to crossload configuration group." ) else: - self.crossload_group(self.configure_group, configcog.configure_group) + self.crossload_group(self.configure_group, configcog.config_group) if self.bot.is_ready(): await self.initialise() @@ -318,7 +318,6 @@ class TextTrackerCog(LionCog): xp_per_period=TextTrackerSettings.XPPerPeriod._desc, word_xp=TextTrackerSettings.WordXP._desc, ) - @appcmds.default_permissions(manage_guild=True) @low_management_ward async def configure_text_tracking_cmd(self, ctx: LionContext, xp_per_period: Optional[appcmds.Range[int, 0, 2**15]] = None, diff --git a/src/tracking/text/settings.py b/src/tracking/text/settings.py index f25b2773..3f7ee3a9 100644 --- a/src/tracking/text/settings.py +++ b/src/tracking/text/settings.py @@ -11,6 +11,7 @@ from meta.sharding import THIS_SHARD from meta.logger import log_wrap from core.data import CoreData from babel.translator import ctx_translator +from wards import low_management_iward from . import babel, logger from .data import TextTrackerData @@ -28,7 +29,8 @@ class TextTrackerSettings(SettingGroup): """ class XPPerPeriod(ModelData, IntegerSetting): setting_id = 'xp_per_period' - _set_cmd = 'configure message_exp' + _set_cmd = 'config message_exp' + _write_ward = low_management_iward _display_name = _p('guildset:xp_per_period', "xp_per_5min") _desc = _p( @@ -60,7 +62,8 @@ class TextTrackerSettings(SettingGroup): class WordXP(ModelData, IntegerSetting): setting_id = 'word_xp' - _set_cmd = 'configure message_exp' + _set_cmd = 'config message_exp' + _write_ward = low_management_iward _display_name = _p('guildset:word_xp', "xp_per_100words") _desc = _p( @@ -91,6 +94,7 @@ class TextTrackerSettings(SettingGroup): class UntrackedTextChannels(ListData, ChannelListSetting): setting_id = 'untracked_text_channels' + _write_ward = low_management_iward _display_name = _p('guildset:untracked_text_channels', "untracked_text_channels") _desc = _p( diff --git a/src/tracking/text/ui.py b/src/tracking/text/ui.py index fa6bfa52..8a00a27e 100644 --- a/src/tracking/text/ui.py +++ b/src/tracking/text/ui.py @@ -35,6 +35,7 @@ class TextTrackerConfigUI(ConfigUI): async def untracked_channels_menu(self, selection: discord.Interaction, selected): await selection.response.defer() setting = self.instances[2] + await setting.interaction_check(setting.parent_id, selection) setting.value = selected.values await setting.write() @@ -86,7 +87,7 @@ class TextTrackerConfigUI(ConfigUI): class TextTrackerDashboard(DashboardSection): section_name = _p( 'dash:text_tracking|title', - "Message XP configuration ({commands[configure message_exp]})", + "Message XP configuration ({commands[config message_exp]})", ) _option_name = _p( "dash:text_tracking|dropdown|placeholder", diff --git a/src/tracking/voice/cog.py b/src/tracking/voice/cog.py index f85f138d..119693e5 100644 --- a/src/tracking/voice/cog.py +++ b/src/tracking/voice/cog.py @@ -133,7 +133,7 @@ class VoiceTrackerCog(LionCog): "Attempting to load VoiceTrackerCog before ConfigCog! Cannot crossload configuration group." ) else: - self.crossload_group(self.configure_group, configcog.configure_group) + self.crossload_group(self.configure_group, configcog.config_group) if self.bot.is_ready(): await self.initialise() @@ -867,7 +867,6 @@ class VoiceTrackerCog(LionCog): hourly_live_bonus=VoiceTrackerSettings.HourlyLiveBonus._desc, daily_voice_cap=VoiceTrackerSettings.DailyVoiceCap._desc, ) - @appcmds.default_permissions(manage_guild=True) @low_management_ward async def configure_voice_tracking_cmd(self, ctx: LionContext, hourly_reward: Optional[int] = None, # TODO: Change these to Ranges diff --git a/src/tracking/voice/settings.py b/src/tracking/voice/settings.py index 4d74a3da..bfa90c67 100644 --- a/src/tracking/voice/settings.py +++ b/src/tracking/voice/settings.py @@ -14,6 +14,7 @@ from meta.sharding import THIS_SHARD from meta.logger import log_wrap from utils.lib import MessageArgs from utils.ui import LeoUI, ConfigUI, DashboardSection +from wards import low_management_iward from core.data import CoreData from core.lion_guild import VoiceMode @@ -35,7 +36,8 @@ class VoiceTrackerSettings(SettingGroup): class UntrackedChannels(ListData, ChannelListSetting): setting_id = 'untracked_channels' _event = 'guildset_untracked_channels' - _set_cmd = 'configure voice_rewards' + _set_cmd = 'config voice_rewards' + _write_ward = low_management_iward _display_name = _p('guildset:untracked_channels', "untracked_channels") _desc = _p( @@ -112,7 +114,8 @@ class VoiceTrackerSettings(SettingGroup): class HourlyReward(ModelData, IntegerSetting): setting_id = 'hourly_reward' _event = 'on_guildset_hourly_reward' - _set_cmd = 'configure voice_rewards' + _set_cmd = 'config voice_rewards' + _write_ward = low_management_iward _display_name = _p('guildset:hourly_reward', "hourly_reward") _desc = _p( @@ -192,7 +195,8 @@ class VoiceTrackerSettings(SettingGroup): """ setting_id = 'hourly_live_bonus' _event = 'on_guildset_hourly_live_bonus' - _set_cmd = 'configure voice_rewards' + _set_cmd = 'config voice_rewards' + _write_ward = low_management_iward _display_name = _p('guildset:hourly_live_bonus', "hourly_live_bonus") _desc = _p( @@ -243,7 +247,8 @@ class VoiceTrackerSettings(SettingGroup): class DailyVoiceCap(ModelData, DurationSetting): setting_id = 'daily_voice_cap' _event = 'on_guildset_daily_voice_cap' - _set_cmd = 'configure voice_rewards' + _set_cmd = 'config voice_rewards' + _write_ward = low_management_iward _display_name = _p('guildset:daily_voice_cap', "daily_voice_cap") _desc = _p( @@ -465,6 +470,7 @@ class VoiceTrackerConfigUI(ConfigUI): async def untracked_channels_menu(self, selection: discord.Interaction, selected): await selection.response.defer() setting = self.instances[3] + await setting.interaction_check(setting.parent_id, selection) setting.value = selected.values await setting.write() @@ -528,7 +534,7 @@ class VoiceTrackerConfigUI(ConfigUI): class VoiceTrackerDashboard(DashboardSection): section_name = _p( 'dash:voice_tracker|title', - "Voice Tracker Configuration ({commands[configure voice_rewards]})" + "Voice Tracker Configuration ({commands[config voice_rewards]})" ) _option_name = _p( "dash:voice_tracking|dropdown|placeholder", diff --git a/src/utils/lib.py b/src/utils/lib.py index 98d60c57..9ee0150a 100644 --- a/src/utils/lib.py +++ b/src/utils/lib.py @@ -1,6 +1,8 @@ -from typing import NamedTuple, Optional, Sequence, Union, overload, List +from io import StringIO +from typing import NamedTuple, Optional, Sequence, Union, overload, List, Any import collections import datetime +import datetime as dt import iso8601 # type: ignore import pytz import re @@ -11,8 +13,10 @@ import discord from discord.partial_emoji import _EmojiTag from discord import Embed, File, GuildSticker, StickerItem, AllowedMentions, Message, MessageReference, PartialMessage from discord.ui import View +from dateutil.parser import parse, ParserError from babel.translator import ctx_translator +from meta.errors import UserInputError from . import util_babel @@ -887,3 +891,30 @@ def _recurse_length(payload, breadcrumbs={}, header=()) -> int: breadcrumbs.pop(total_header) return total + +async def parse_time_static(timestr, timezone): + timestr = timestr.strip() + default = dt.datetime.now(tz=timezone).replace(hour=0, minute=0, second=0, microsecond=0) + if not timestr: + return default + try: + ts = parse(timestr, fuzzy=True, default=default) + except ParserError: + t = ctx_translator.get().t + raise UserInputError( + t(_p( + 'parse_timestamp|error:parse', + "Could not parse `{given}` as a valid reminder time. " + "Try entering the time in the form `HH:MM` or `YYYY-MM-DD HH:MM`." + )).format(given=timestr) + ) + return ts + +def write_records(records: list[dict[str, Any]], stream: StringIO): + if records: + keys = records[0].keys() + stream.write(','.join(keys)) + stream.write('\n') + for record in records: + stream.write(','.join(map(str, record.values()))) + stream.write('\n') diff --git a/src/utils/ui/config.py b/src/utils/ui/config.py index e1567e1f..93482ca8 100644 --- a/src/utils/ui/config.py +++ b/src/utils/ui/config.py @@ -126,6 +126,7 @@ class ConfigUI(LeoUI): new_data = None else: # If this raises a UserInputError, it will be caught and the modal retried + await setting.interaction_check(setting.parent_id, interaction) new_data = await setting._parse_string(setting.parent_id, input_value) setting.data = new_data modified.append(setting) diff --git a/src/wards.py b/src/wards.py index 5db46eb9..c552337c 100644 --- a/src/wards.py +++ b/src/wards.py @@ -26,15 +26,31 @@ async def high_management(bot: LionBot, member: discord.Member, guild: discord.G return True if await sys_admin(bot, member.id): return True - return member.guild_permissions.administrator + if member.guild_permissions.administrator: + return True + lguild = await bot.core.lions.fetch_guild(guild.id) + adminrole = lguild.data.admin_role + roleids = [role.id for role in member.roles] + if (adminrole and adminrole in roleids): + return True async def low_management(bot: LionBot, member: discord.Member, guild: discord.Guild): + """ + Low management is currently identified with moderator permissions. + """ if not guild: return True if await high_management(bot, member, guild): return True - return member.guild_permissions.manage_guild + if member.guild_permissions.manage_guild: + return True + + lguild = await bot.core.lions.fetch_guild(guild.id) + modrole = lguild.data.mod_role + roleids = [role.id for role in member.roles] + if (modrole and modrole in roleids): + return True # Interaction Wards, also return True/False @@ -96,7 +112,7 @@ async def high_management_ward(ctx: LionContext) -> bool: raise CheckFailure( ctx.bot.translator.t(_p( 'ward:high_management|failed', - "You must have the `ADMINISTRATOR` permission in this server to do this!" + "You must have the `ADMINISTRATOR` permission or the configured `admin_role` to do this!" )) ) @@ -112,7 +128,7 @@ async def low_management_ward(ctx: LionContext) -> bool: raise CheckFailure( ctx.bot.translator.t(_p( 'ward:low_management|failed', - "You must have the `MANAGE_GUILD` permission in this server to do this!" + "You must have the `MANAGE_GUILD` permission or the configured `mod_role` to do this!" )) )