Merge branch 'rewrite' into pillow
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
CONFIG_FILE = "config/bot.conf"
|
||||
DATA_VERSION = 13
|
||||
DATA_VERSION = 14
|
||||
|
||||
MAX_COINS = 2147483647 - 1
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
2
src/gui
2
src/gui
Submodule src/gui updated: f2760218ef...c1bcb05c25
@@ -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__}")
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <gid: {guild.id}>")
|
||||
# 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 <gid: {guild.id}>",
|
||||
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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
2
src/modules/moderation/tickets/__init__.py
Normal file
2
src/modules/moderation/tickets/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .note import NoteTicket
|
||||
from .warning import WarnTicket
|
||||
48
src/modules/moderation/tickets/note.py
Normal file
48
src/modules/moderation/tickets/note.py
Normal file
@@ -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
|
||||
66
src/modules/moderation/tickets/warning.py
Normal file
66
src/modules/moderation/tickets/warning.py
Normal file
@@ -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
|
||||
|
||||
|
||||
|
||||
656
src/modules/moderation/ticketui.py
Normal file
656
src/modules/moderation/ticketui.py
Normal file
@@ -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()
|
||||
@@ -1,7 +0,0 @@
|
||||
# flake8: noqa
|
||||
from .module import module
|
||||
|
||||
from . import help
|
||||
from . import links
|
||||
from . import nerd
|
||||
from . import join_message
|
||||
@@ -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 <command>` (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 <required> 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))
|
||||
)
|
||||
@@ -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
|
||||
@@ -1,5 +0,0 @@
|
||||
guide_link = "https://discord.studylions.com/tutorial"
|
||||
|
||||
animation_link = (
|
||||
"https://media.discordapp.net/attachments/879412267731542047/926837189814419486/ezgif.com-resize.gif"
|
||||
)
|
||||
@@ -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)
|
||||
@@ -1,3 +0,0 @@
|
||||
from LionModule import LionModule
|
||||
|
||||
module = LionModule("Meta")
|
||||
@@ -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 <t:{data_timestamp}:F>",
|
||||
"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)
|
||||
@@ -1,9 +0,0 @@
|
||||
from .module import module
|
||||
|
||||
from . import data
|
||||
from . import admin
|
||||
|
||||
from . import tickets
|
||||
from . import video
|
||||
|
||||
from . import commands
|
||||
@@ -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."
|
||||
|
||||
|
||||
@@ -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 <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 <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("<t:{timestamp:.0f}:d>")
|
||||
# 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 <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
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -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')
|
||||
@@ -1,4 +0,0 @@
|
||||
from cmdClient import Module
|
||||
|
||||
|
||||
module = Module("Moderation")
|
||||
@@ -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 <t:{:.0f}>)".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="<t:{:.0f}>\n(Duration: `{}`)".format(
|
||||
info.expiry.timestamp(),
|
||||
strfdelta(datetime.timedelta(seconds=info.duration))
|
||||
)
|
||||
)
|
||||
elif info.ticket_state == TicketState.EXPIRED:
|
||||
embed.add_field(
|
||||
name="Expired",
|
||||
value="<t:{:.0f}>".format(
|
||||
info.expiry.timestamp(),
|
||||
)
|
||||
)
|
||||
else:
|
||||
embed.add_field(
|
||||
name="Expiry",
|
||||
value="<t:{:.0f}>".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 <t:{:.0f}>.\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()
|
||||
@@ -1,4 +0,0 @@
|
||||
from .Ticket import Ticket, TicketType, TicketState
|
||||
from .studybans import StudyBanTicket
|
||||
from .notes import NoteTicket
|
||||
from .warns import WarnTicket
|
||||
@@ -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 <content>
|
||||
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 <content>`.".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 <content>`.\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)
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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 <reason>
|
||||
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 <reason>`.".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 <reason>`.\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 ''
|
||||
)
|
||||
)
|
||||
@@ -1,5 +0,0 @@
|
||||
from . import module
|
||||
|
||||
from . import data
|
||||
from . import config
|
||||
from . import commands
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -1,4 +0,0 @@
|
||||
from data import Table
|
||||
|
||||
|
||||
guild_whitelist = Table("sponsor_guild_whitelist")
|
||||
@@ -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)
|
||||
@@ -1,6 +0,0 @@
|
||||
from .module import module
|
||||
|
||||
from . import webhook
|
||||
from . import commands
|
||||
from . import data
|
||||
from . import settings
|
||||
@@ -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)
|
||||
@@ -1,9 +0,0 @@
|
||||
from data.interfaces import RowTable, Table
|
||||
|
||||
topggvotes = RowTable(
|
||||
'topgg',
|
||||
('voteid', 'userid', 'boostedTimestamp'),
|
||||
'voteid'
|
||||
)
|
||||
|
||||
guild_whitelist = Table('topgg_guild_whitelist')
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"))
|
||||
@@ -1,5 +0,0 @@
|
||||
from .module import module
|
||||
|
||||
from . import admin
|
||||
from . import data
|
||||
from . import tracker
|
||||
@@ -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."
|
||||
@@ -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'
|
||||
)
|
||||
@@ -1,4 +0,0 @@
|
||||
from LionModule import LionModule
|
||||
|
||||
|
||||
module = LionModule("Workout")
|
||||
@@ -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
|
||||
)
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
10
src/modules/premium/__init__.py
Normal file
10
src/modules/premium/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
import logging
|
||||
from babel.translator import LocalBabel
|
||||
|
||||
babel = LocalBabel('premium')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def setup(bot):
|
||||
from .cog import PremiumCog
|
||||
await bot.add_cog(PremiumCog(bot))
|
||||
713
src/modules/premium/cog.py
Normal file
713
src/modules/premium/cog.py
Normal file
@@ -0,0 +1,713 @@
|
||||
from typing import Optional
|
||||
import asyncio
|
||||
|
||||
import discord
|
||||
from discord.ext import commands as cmds
|
||||
import discord.app_commands as appcmds
|
||||
|
||||
from discord.ui.button import Button, ButtonStyle
|
||||
from discord.ui.text_input import TextInput, TextStyle
|
||||
|
||||
from meta import LionCog, LionBot, LionContext
|
||||
from meta.errors import SafeCancellation, UserInputError
|
||||
from meta.logger import log_wrap
|
||||
from utils.lib import utc_now
|
||||
from utils.ui import FastModal
|
||||
from wards import sys_admin_ward
|
||||
from constants import MAX_COINS
|
||||
|
||||
from . import logger, babel
|
||||
from .data import PremiumData, GemTransactionType
|
||||
from .ui.transactions import TransactionList
|
||||
from .ui.premium import PremiumUI
|
||||
from .errors import GemTransactionFailed, BalanceTooLow, BalanceTooHigh
|
||||
|
||||
_p = babel._p
|
||||
|
||||
|
||||
class PremiumCog(LionCog):
|
||||
buy_gems_link = "https://lionbot.org/donate"
|
||||
|
||||
def __init__(self, bot: LionBot):
|
||||
self.bot = bot
|
||||
self.data: PremiumData = bot.db.load_registry(PremiumData())
|
||||
|
||||
self.gem_logger: Optional[discord.Webhook] = None
|
||||
|
||||
async def cog_load(self):
|
||||
await self.data.init()
|
||||
|
||||
if (leo_setting_cog := self.bot.get_cog('LeoSettings')) is not None:
|
||||
self.crossload_group(self.leo_group, leo_setting_cog.leo_group)
|
||||
|
||||
if (gem_log_url := self.bot.config.endpoints.get('gem_log', None)) is not None:
|
||||
self.gem_logger = discord.Webhook.from_url(gem_log_url, session=self.bot.web_client)
|
||||
|
||||
|
||||
# ----- API -----
|
||||
def buy_gems_button(self) -> Button:
|
||||
t = self.bot.translator.t
|
||||
|
||||
button = Button(
|
||||
style=ButtonStyle.link,
|
||||
label=t(_p(
|
||||
'button:gems|label',
|
||||
"Buy Gems"
|
||||
)),
|
||||
emoji=self.bot.config.emojis.gem,
|
||||
url=self.buy_gems_link,
|
||||
)
|
||||
return button
|
||||
|
||||
async def get_gem_balance(self, userid: int) -> int:
|
||||
"""
|
||||
Get the up-to-date gem balance for this user.
|
||||
|
||||
Creates the User row if it does not already exist.
|
||||
"""
|
||||
record = await self.bot.core.data.User.fetch(userid, cached=False)
|
||||
if record is None:
|
||||
record = await self.bot.core.data.User.create(userid=userid)
|
||||
return record.gems
|
||||
|
||||
async def get_gift_count(self, userid: int) -> int:
|
||||
"""
|
||||
Compute the number of gifts this user has sent, by counting Transaction rows.
|
||||
"""
|
||||
record = await self.data.GemTransaction.table.select_where(
|
||||
from_account=userid,
|
||||
transaction_type=GemTransactionType.GIFT,
|
||||
).select(
|
||||
gift_count='COUNT(*)'
|
||||
).with_no_adapter()
|
||||
|
||||
return record[0]['gift_count'] or 0
|
||||
|
||||
async def is_premium_guild(self, guildid: int) -> bool:
|
||||
"""
|
||||
Check whether the given guild currently has premium status.
|
||||
"""
|
||||
row = await self.data.PremiumGuild.fetch(guildid)
|
||||
now = utc_now()
|
||||
|
||||
premium = (row is not None) and row.premium_until and (row.premium_until > now)
|
||||
return premium
|
||||
|
||||
@log_wrap(isolate=True)
|
||||
async def _add_gems(self, userid: int, amount: int):
|
||||
"""
|
||||
Transaction helper method to atomically add `amount` gems to account `userid`,
|
||||
creating the account if required.
|
||||
|
||||
Do not use this method for a gem transaction. Use `gem_transaction` instead.
|
||||
"""
|
||||
async with self.bot.db.connection() as conn:
|
||||
self.bot.db.conn = conn
|
||||
async with conn.transaction():
|
||||
model = self.bot.core.data.User
|
||||
rows = await model.table.update_where(userid=userid).set(gems=model.gems + amount)
|
||||
if not rows:
|
||||
# User does not exist, create it
|
||||
if amount < 0:
|
||||
raise BalanceTooLow
|
||||
if amount > MAX_COINS:
|
||||
raise BalanceTooHigh
|
||||
row = (await model.create(userid=userid, gems=amount)).data
|
||||
else:
|
||||
row = rows[0]
|
||||
|
||||
if row['gems'] < 0:
|
||||
raise BalanceTooLow
|
||||
|
||||
async def gem_transaction(
|
||||
self,
|
||||
transaction_type: GemTransactionType,
|
||||
*,
|
||||
actorid: int,
|
||||
from_account: Optional[int], to_account: Optional[int],
|
||||
amount: int, description: str,
|
||||
note: Optional[str] = None, reference: Optional[str] = None,
|
||||
) -> PremiumData.GemTransaction:
|
||||
"""
|
||||
Perform a gem transaction with the given parameters.
|
||||
|
||||
This atomically creates a row in the 'gem_transactions' table,
|
||||
updates the account balances,
|
||||
and posts in the gem audit log.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
transaction_type: GemTransactionType
|
||||
The type of transaction.
|
||||
actorid: int
|
||||
The userid of the actor who initiated this transaction.
|
||||
Automatic actions (e.g. webhook triggered) may have their own unique id.
|
||||
from_account: Optional[int]
|
||||
The userid of the source account.
|
||||
May be `None` if there is no source account (e.g. manual modification by admin).
|
||||
to_account: Optional[int]
|
||||
The userid of the destination account.
|
||||
May be `None` if there is no destination account.
|
||||
amount: int
|
||||
The number of LionGems to transfer.
|
||||
description: str
|
||||
An informative description of the transaction for auditing purposes.
|
||||
Should include the pathway (e.g. command) through which the transaction was executed.
|
||||
note: Optional[str]
|
||||
Optional user-readable note added by the actor.
|
||||
Usually attached in a notification visible by the target.
|
||||
(E.g. thanks message from system/admin, or note attached to gift.)
|
||||
reference: str
|
||||
Optional admin-readable transaction reference.
|
||||
This may be the message link of a command message,
|
||||
or an external id/reference for an automatic transaction.
|
||||
|
||||
Raises
|
||||
------
|
||||
BalanceTooLow:
|
||||
Raised if either source or target account would go below 0.
|
||||
"""
|
||||
async with self.bot.db.connection() as conn:
|
||||
self.bot.db.conn = conn
|
||||
async with conn.transaction():
|
||||
if from_account is not None:
|
||||
await self._add_gems(from_account, -amount)
|
||||
if to_account is not None:
|
||||
await self._add_gems(to_account, amount)
|
||||
|
||||
row = await self.data.GemTransaction.create(
|
||||
transaction_type=transaction_type,
|
||||
actorid=actorid,
|
||||
from_account=from_account,
|
||||
to_account=to_account,
|
||||
amount=amount,
|
||||
description=description,
|
||||
note=note,
|
||||
reference=reference,
|
||||
)
|
||||
logger.info(
|
||||
f"LionGem Transaction performed. Transaction data: {row!r}"
|
||||
)
|
||||
await self.audit_log(row)
|
||||
return row
|
||||
|
||||
async def audit_log(self, row: PremiumData.GemTransaction):
|
||||
"""
|
||||
Log the provided gem transaction to the global gem audit log.
|
||||
|
||||
If this fails, or the audit log does not exist, logs a warning.
|
||||
"""
|
||||
posted = False
|
||||
if self.gem_logger is not None:
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
title=f"Gem Transaction #{row.transactionid}",
|
||||
timestamp=row._timestamp,
|
||||
)
|
||||
embed.add_field(name="Type", value=row.transaction_type.name)
|
||||
embed.add_field(name="Amount", value=str(row.amount))
|
||||
embed.add_field(name="Actor", value=f"<@{row.actorid}>")
|
||||
embed.add_field(name="From Account", value=f"<@{row.from_account}>" if row.from_account else 'None')
|
||||
embed.add_field(name="To Account", value=f"<@{row.to_account}>" if row.to_account else 'None')
|
||||
embed.add_field(name='Description', value=str(row.description), inline=False)
|
||||
if row.note:
|
||||
embed.add_field(name='Note', value=str(row.note), inline=False)
|
||||
if row.reference:
|
||||
embed.add_field(name='Reference', value=str(row.reference), inline=False)
|
||||
|
||||
try:
|
||||
await self.gem_logger.send(embed=embed)
|
||||
posted = True
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
if not posted:
|
||||
logger.warning(
|
||||
f"Missed gem audit logging for gem transaction: {row!r}"
|
||||
)
|
||||
|
||||
# ----- User Commands -----
|
||||
@cmds.hybrid_command(
|
||||
name=_p('cmd:free', "free"),
|
||||
description=_p(
|
||||
'cmd:free|desc',
|
||||
"Get free LionGems!"
|
||||
)
|
||||
)
|
||||
async def cmd_free(self, ctx: LionContext):
|
||||
t = self.bot.translator.t
|
||||
content = t(_p(
|
||||
'cmd:free|embed|description',
|
||||
"You can get free LionGems by sharing our project on your Discord server and social media!\n"
|
||||
"If you have well-established, or YouTube, Instagram, and TikTok accounts,"
|
||||
" we will reward you for creating videos and content about the bot.\n"
|
||||
"If you have a big server, you can promote our project and get LionGems in return.\n"
|
||||
"For more details, contact `arihoresh` or open a Ticket in the [support server](https://discord.gg/studylions)."
|
||||
))
|
||||
thumb = "https://cdn.discordapp.com/attachments/890619584158265405/972791204498530364/Untitled_design_44.png"
|
||||
title = t(_p(
|
||||
'cmd:free|embed|title',
|
||||
"Get FREE LionGems!"
|
||||
))
|
||||
embed = discord.Embed(
|
||||
title=title,
|
||||
description=content,
|
||||
colour=0x41f097
|
||||
)
|
||||
embed.set_thumbnail(url=thumb)
|
||||
|
||||
await ctx.reply(embed=embed)
|
||||
|
||||
@cmds.hybrid_command(
|
||||
name=_p('cmd:gift', "gift"),
|
||||
description=_p(
|
||||
'cmd:gift|desc',
|
||||
"Gift your LionGems to another user!"
|
||||
)
|
||||
)
|
||||
@appcmds.rename(
|
||||
user=_p('cmd:gift|param:user', "user"),
|
||||
amount=_p('cmd:gift|param:amount', "amount"),
|
||||
note=_p('cmd:gift|param:note', "note"),
|
||||
)
|
||||
@appcmds.describe(
|
||||
user=_p(
|
||||
'cmd:gift|param:user|desc',
|
||||
"User to which you want to gift your LionGems."
|
||||
),
|
||||
amount=_p(
|
||||
'cmd:gift|param:amount|desc',
|
||||
"Number of LionGems to gift."
|
||||
),
|
||||
note=_p(
|
||||
'cmd:gift|param:note|desc',
|
||||
"Optional note to attach to your gift."
|
||||
),
|
||||
)
|
||||
async def cmd_gift(self, ctx: LionContext,
|
||||
user: discord.User,
|
||||
amount: appcmds.Range[int, 1, MAX_COINS],
|
||||
note: Optional[appcmds.Range[str, 1, 1024]] = None):
|
||||
if not ctx.interaction:
|
||||
return
|
||||
t = self.bot.translator.t
|
||||
|
||||
# Validate target
|
||||
if user.bot:
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'cmd:gift|error:target_bot',
|
||||
"You cannot gift LionGems to bots!"
|
||||
))
|
||||
)
|
||||
|
||||
if user.id == ctx.author.id:
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'cmd:gift|error:target_is_author',
|
||||
"You cannot gift LionGems to yourself!"
|
||||
))
|
||||
)
|
||||
|
||||
# Prepare and open gift confirmation modal
|
||||
amount_field = TextInput(
|
||||
label=t(_p(
|
||||
'cmd:gift|modal:confirm|field:amount|label',
|
||||
"Number of LionGems to Gift"
|
||||
)),
|
||||
default=str(amount),
|
||||
required=True,
|
||||
)
|
||||
note_field = TextInput(
|
||||
label=t(_p(
|
||||
'cmd:gift|modal:confirm|field:note|label',
|
||||
"Add an optional note to your gift"
|
||||
)),
|
||||
default=note or '',
|
||||
required=False,
|
||||
max_length=1024,
|
||||
style=TextStyle.long,
|
||||
)
|
||||
modal = FastModal(
|
||||
amount_field, note_field,
|
||||
title=t(_p(
|
||||
'cmd:gift|modal:confirm|title',
|
||||
"Confirm LionGem Gift"
|
||||
))
|
||||
)
|
||||
|
||||
await ctx.interaction.response.send_modal(modal)
|
||||
|
||||
try:
|
||||
interaction = await modal.wait_for(timeout=300)
|
||||
except asyncio.TimeoutError:
|
||||
# Presume user cancelled and wants to abort
|
||||
raise SafeCancellation
|
||||
|
||||
await interaction.response.defer(thinking=False)
|
||||
|
||||
# Parse amount
|
||||
amountstr = amount_field.value
|
||||
if not amountstr.isdigit():
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'cmd:gift|error:parse_amount',
|
||||
"Could not parse `{provided}` as a number!"
|
||||
)).format(provided=amountstr)
|
||||
)
|
||||
amount = int(amountstr)
|
||||
|
||||
if amount == 0:
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'cmd:gift|error:amount_zero',
|
||||
"Cannot gift `0` gems."
|
||||
))
|
||||
)
|
||||
|
||||
# Get author's balance, make sure they have enough
|
||||
author_balance = await self.get_gem_balance(ctx.author.id)
|
||||
if author_balance < amount:
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'cmd:gift|error:author_balance_too_low',
|
||||
"Insufficient balance to send {gem}**{amount}**!\n"
|
||||
"Current balance: {gem}**{balance}**"
|
||||
)).format(
|
||||
gem=self.bot.config.emojis.gem,
|
||||
amount=amount,
|
||||
balance=author_balance,
|
||||
)
|
||||
)
|
||||
|
||||
# Everything seems to be in order, run the transaction
|
||||
try:
|
||||
transaction = await self.gem_transaction(
|
||||
GemTransactionType.GIFT,
|
||||
actorid=ctx.author.id,
|
||||
from_account=ctx.author.id, to_account=user.id,
|
||||
amount=amount,
|
||||
description="Gift given through command '/gift'",
|
||||
note=note_field.value or None
|
||||
)
|
||||
except BalanceTooLow:
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'cmd:gift|error:balance_too_low',
|
||||
"Insufficient Balance to complete gift!"
|
||||
))
|
||||
)
|
||||
|
||||
# Attempt to send note to user
|
||||
|
||||
thumb = "https://cdn.discordapp.com/attachments/925799205954543636/938704034578194443/C85AF926-9F75-466F-9D8E-D47721427F5D.png"
|
||||
icon = "https://cdn.discordapp.com/attachments/925799205954543636/938703943683416074/4CF1C849-D532-4DEC-B4C9-0AB11F443BAB.png"
|
||||
desc = t(_p(
|
||||
'cmd:gift|target_msg|desc',
|
||||
"You were just gifted {gem}**{amount}** by {user}!\n"
|
||||
"To use them, use the command {skin_cmd} to change your graphics skin!"
|
||||
)).format(
|
||||
gem=self.bot.config.emojis.gem,
|
||||
amount=amount,
|
||||
user=ctx.author.mention,
|
||||
skin_cmd=self.bot.core.mention_cmd('my skin'),
|
||||
)
|
||||
embed = discord.Embed(
|
||||
description=desc,
|
||||
colour=discord.Colour.orange()
|
||||
)
|
||||
embed.set_thumbnail(url=thumb)
|
||||
embed.set_author(
|
||||
name=t(_p('cmd:gift|target_msg|author:name', "LionGems Delivery!")),
|
||||
icon_url=icon,
|
||||
)
|
||||
embed.set_footer(
|
||||
text=t(_p(
|
||||
'cmd:gift|target_msg|footer:text',
|
||||
"You now have {balance} LionGems"
|
||||
)).format(
|
||||
balance=await self.get_gem_balance(user.id),
|
||||
)
|
||||
)
|
||||
embed.timestamp = utc_now()
|
||||
|
||||
note = note_field.value
|
||||
if note:
|
||||
embed.add_field(
|
||||
name=t(_p(
|
||||
'cmd:gift|target_msg|field:note|name',
|
||||
"The sender attached a note"
|
||||
)),
|
||||
value=note
|
||||
)
|
||||
|
||||
notify_sent = False
|
||||
try:
|
||||
await user.send(embed=embed)
|
||||
notify_sent = True
|
||||
except discord.HTTPException:
|
||||
logger.info(
|
||||
f"Could not send LionGem gift target their gift notification. Transaction {transaction.transactionid}"
|
||||
)
|
||||
|
||||
# Finally, send the ack back to the author
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.brand_green(),
|
||||
title=t(_p(
|
||||
'cmd:gift|embed:success|title',
|
||||
"Gift Sent!"
|
||||
)),
|
||||
description=t(_p(
|
||||
'cmd:gift|embed:success|description',
|
||||
"Your gift of {gem}**{amount}** is on its way to {target}!"
|
||||
)).format(
|
||||
gem=self.bot.config.emojis.gem,
|
||||
amount=amount,
|
||||
target=user.mention,
|
||||
)
|
||||
)
|
||||
embed.set_footer(
|
||||
text=t(_p(
|
||||
'cmd:gift|embed:success|footer',
|
||||
"New Balance: {balance} LionGems",
|
||||
)).format(balance=await self.get_gem_balance(ctx.author.id))
|
||||
)
|
||||
if not notify_sent:
|
||||
embed.add_field(
|
||||
name="",
|
||||
value=t(_p(
|
||||
'cmd:gift|embed:success|field:notify_failed|value',
|
||||
"Unfortunately, I couldn't tell them about it! "
|
||||
"They might have direct messages with me turned off."
|
||||
))
|
||||
)
|
||||
|
||||
await ctx.reply(embed=embed, ephemeral=True)
|
||||
|
||||
@cmds.hybrid_command(
|
||||
name=_p('cmd:premium', "premium"),
|
||||
description=_p(
|
||||
'cmd:premium|desc',
|
||||
"Upgrade your server with LionGems!"
|
||||
)
|
||||
)
|
||||
@appcmds.guild_only
|
||||
async def cmd_premium(self, ctx: LionContext):
|
||||
if not ctx.guild:
|
||||
return
|
||||
if not ctx.interaction:
|
||||
return
|
||||
|
||||
ui = PremiumUI(self.bot, ctx.guild, ctx.luser, callerid=ctx.author.id)
|
||||
await ui.run(ctx.interaction)
|
||||
await ui.wait()
|
||||
|
||||
# ----- Owner Commands -----
|
||||
@LionCog.placeholder_group
|
||||
@cmds.hybrid_group("leo", with_app_command=False)
|
||||
async def leo_group(self, ctx: LionContext):
|
||||
...
|
||||
|
||||
|
||||
@leo_group.command(
|
||||
name=_p('cmd:leo_gems', "gems"),
|
||||
description=_p(
|
||||
'cmd:leo_gems|desc',
|
||||
"View and adjust a user's LionGem balance."
|
||||
)
|
||||
)
|
||||
@appcmds.rename(
|
||||
target=_p('cmd:leo_gems|param:target', "target"),
|
||||
adjustment=_p('cmd:leo_gems|param:adjustment', "adjustment"),
|
||||
note=_p('cmd:leo_gems|param:note', "note"),
|
||||
reason=_p('cmd:leo_gems|param:reason', "reason")
|
||||
)
|
||||
@appcmds.describe(
|
||||
target=_p(
|
||||
'cmd:leo_gems|param:target|desc',
|
||||
"Target user you wish to view or modify LionGems for."
|
||||
),
|
||||
adjustment=_p(
|
||||
'cmd:leo_gems|param:adjustment|desc',
|
||||
"Number of LionGems to add to the target's balance (may be negative to remove)"
|
||||
),
|
||||
note=_p(
|
||||
'cmd:leo_gems|param:note|desc',
|
||||
"Optional note to attach to the delivery message when adding LionGems."
|
||||
),
|
||||
reason=_p(
|
||||
'cmd:leo_gems|param:reason|desc',
|
||||
'Optional reason or context to add to the gem audit log for this transaction.'
|
||||
)
|
||||
)
|
||||
@sys_admin_ward
|
||||
async def cmd_leo_gems(self, ctx: LionContext,
|
||||
target: discord.User,
|
||||
adjustment: Optional[int] = None,
|
||||
note: Optional[appcmds.Range[str, 0, 1024]] = None,
|
||||
reason: Optional[appcmds.Range[str, 0, 1024]] = None,):
|
||||
if not ctx.interaction:
|
||||
return
|
||||
t = self.bot.translator.t
|
||||
|
||||
if adjustment is None or adjustment == 0:
|
||||
# History viewing pathway
|
||||
ui = TransactionList(self.bot, target.id, callerid=ctx.author.id)
|
||||
await ui.run(ctx.interaction)
|
||||
await ui.wait()
|
||||
else:
|
||||
# Adjustment path
|
||||
# Show confirmation modal with note and reason
|
||||
adjustment_field = TextInput(
|
||||
label=t(_p(
|
||||
'cmd:leo_gems|adjust|modal:confirm|field:amount|label',
|
||||
"Number of LionGems to add. May be negative."
|
||||
)),
|
||||
default=str(adjustment),
|
||||
required=True,
|
||||
)
|
||||
note_field = TextInput(
|
||||
label=t(_p(
|
||||
'cmd:leo_gems|adjust|modal:confirm|field:note|label',
|
||||
"Optional note to attach to delivery message."
|
||||
)),
|
||||
default=note,
|
||||
style=TextStyle.long,
|
||||
max_length=1024,
|
||||
required=False,
|
||||
)
|
||||
reason_field = TextInput(
|
||||
label=t(_p(
|
||||
'cmd:leo_gems|adjust|modal:confirm|field:reason|label',
|
||||
"Optional reason to add to the audit log."
|
||||
)),
|
||||
default=reason,
|
||||
style=TextStyle.long,
|
||||
max_length=1024,
|
||||
required=False,
|
||||
)
|
||||
|
||||
modal = FastModal(
|
||||
adjustment_field, note_field, reason_field,
|
||||
title=t(_p(
|
||||
'cmd:leo_gems|adjust|modal:confirm|title',
|
||||
"Confirm LionGem Adjustment"
|
||||
))
|
||||
)
|
||||
await ctx.interaction.response.send_modal(modal)
|
||||
|
||||
try:
|
||||
interaction = await modal.wait_for(timeout=300)
|
||||
except asyncio.TimeoutError:
|
||||
raise SafeCancellation
|
||||
|
||||
await interaction.response.defer(thinking=False)
|
||||
|
||||
# Parse values
|
||||
try:
|
||||
amount = int(adjustment_field.value)
|
||||
except ValueError:
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'cmd:leo_gems|adjust|error:parse_adjustment',
|
||||
"Could not parse `{given}` as an integer."
|
||||
)).format(given=adjustment_field.value)
|
||||
)
|
||||
note = note_field.value or None
|
||||
reason = reason_field.value or None
|
||||
|
||||
# Run transaction
|
||||
try:
|
||||
transaction = await self.gem_transaction(
|
||||
GemTransactionType.ADMIN,
|
||||
actorid=ctx.author.id,
|
||||
from_account=None, to_account=target.id,
|
||||
amount=amount,
|
||||
description=f"Admin balance adjustment with '/leo gems'.\n{reason}",
|
||||
note=note
|
||||
)
|
||||
except GemTransactionFailed:
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'cmd:leo_gems|adjust|error:unknown',
|
||||
"Balance adjustment failed! Check logs for more information."
|
||||
))
|
||||
)
|
||||
# DM user with note if applicable
|
||||
if amount > 0:
|
||||
thumb = "https://cdn.discordapp.com/attachments/925799205954543636/938704034578194443/C85AF926-9F75-466F-9D8E-D47721427F5D.png"
|
||||
icon = "https://cdn.discordapp.com/attachments/925799205954543636/938703943683416074/4CF1C849-D532-4DEC-B4C9-0AB11F443BAB.png"
|
||||
desc = t(_p(
|
||||
'cmd:leo_gems|adjust|target_msg|desc',
|
||||
"You were given {gem}**{amount}**!\n"
|
||||
"To use them, use the command {skin_cmd} to change your graphics skin!"
|
||||
)).format(
|
||||
gem=self.bot.config.emojis.gem,
|
||||
amount=amount,
|
||||
skin_cmd=self.bot.core.mention_cmd('my skin'),
|
||||
)
|
||||
embed = discord.Embed(
|
||||
description=desc,
|
||||
colour=discord.Colour.orange()
|
||||
)
|
||||
embed.set_thumbnail(url=thumb)
|
||||
embed.set_author(
|
||||
name=t(_p('cmd:leo_gems|adjust|target_msg|author:name', "LionGems Delivery!")),
|
||||
icon_url=icon,
|
||||
)
|
||||
embed.set_footer(
|
||||
text=t(_p(
|
||||
'cmd:leo_gems|adjust|target_msg|footer:text',
|
||||
"You now have {balance} LionGems"
|
||||
)).format(
|
||||
balance=await self.get_gem_balance(target.id),
|
||||
)
|
||||
)
|
||||
embed.timestamp = utc_now()
|
||||
|
||||
note = note_field.value
|
||||
if note:
|
||||
embed.add_field(
|
||||
name=t(_p(
|
||||
'cmd:lion_gems|adjust|target_msg|field:note|name',
|
||||
"Note"
|
||||
)),
|
||||
value=note
|
||||
)
|
||||
|
||||
try:
|
||||
await target.send(embed=embed)
|
||||
target_notified = True
|
||||
except discord.HTTPException:
|
||||
target_notified = False
|
||||
else:
|
||||
target_notified = None
|
||||
|
||||
# Ack the operation
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.brand_green(),
|
||||
title=t(_p(
|
||||
'cmd:lion_gems|adjust|embed:success|title',
|
||||
"Success"
|
||||
)),
|
||||
description=t(_p(
|
||||
'cmd:lion_gems|adjust|embed:success|description',
|
||||
"Added {gem}**{amount}** to {target}'s account.\n"
|
||||
"They now have {gem}**{balance}**"
|
||||
)).format(
|
||||
gem=self.bot.config.emojis.gem,
|
||||
target=target.mention,
|
||||
amount=amount,
|
||||
balance=await self.get_gem_balance(target.id),
|
||||
)
|
||||
)
|
||||
if target_notified is False:
|
||||
embed.add_field(
|
||||
name="",
|
||||
value=t(_p(
|
||||
'cmd:lion_gems|adjust|embed:success|field:notify_failed|value',
|
||||
"Could not notify the target, they probably have direct messages disabled."
|
||||
))
|
||||
)
|
||||
|
||||
await ctx.reply(embed=embed, ephemeral=True)
|
||||
92
src/modules/premium/data.py
Normal file
92
src/modules/premium/data.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from enum import Enum
|
||||
|
||||
from psycopg import sql
|
||||
from meta.logger import log_wrap
|
||||
from data import Registry, RowModel, RegisterEnum, Table
|
||||
from data.columns import Integer, Bool, Column, Timestamp, String
|
||||
|
||||
|
||||
class GemTransactionType(Enum):
|
||||
"""
|
||||
Schema
|
||||
------
|
||||
CREATE TYPE GemTransactionType AS ENUM (
|
||||
'ADMIN',
|
||||
'GIFT',
|
||||
'PURCHASE',
|
||||
'AUTOMATIC'
|
||||
);
|
||||
"""
|
||||
ADMIN = 'ADMIN',
|
||||
GIFT = 'GIFT',
|
||||
PURCHASE = 'PURCHASE',
|
||||
AUTOMATIC = 'AUTOMATIC',
|
||||
|
||||
|
||||
class PremiumData(Registry):
|
||||
GemTransactionType = RegisterEnum(GemTransactionType, 'GemTransactionType')
|
||||
|
||||
class GemTransaction(RowModel):
|
||||
"""
|
||||
Schema
|
||||
------
|
||||
|
||||
CREATE TABLE gem_transactions(
|
||||
transactionid SERIAL PRIMARY KEY,
|
||||
transaction_type GemTransactionType NOT NULL,
|
||||
actorid BIGINT NOT NULL,
|
||||
from_account BIGINT,
|
||||
to_account BIGINT,
|
||||
amount INTEGER NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
note TEXT,
|
||||
reference TEXT,
|
||||
_timestamp TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
CREATE INDEX gem_transactions_from ON gem_transactions (from_account);
|
||||
"""
|
||||
_tablename_ = 'gem_transactions'
|
||||
|
||||
transactionid = Integer(primary=True)
|
||||
transaction_type: Column[GemTransactionType] = Column()
|
||||
actorid = Integer()
|
||||
from_account = Integer()
|
||||
to_account = Integer()
|
||||
amount = Integer()
|
||||
description = String()
|
||||
note = String()
|
||||
reference = String()
|
||||
|
||||
_timestamp = Timestamp()
|
||||
|
||||
class PremiumGuild(RowModel):
|
||||
"""
|
||||
Schema
|
||||
------
|
||||
CREATE TABLE premium_guilds(
|
||||
guildid BIGINT PRIMARY KEY REFERENCES guild_config,
|
||||
premium_since TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
premium_until TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
custom_skin_id INTEGER REFERENCES customised_skins
|
||||
);
|
||||
"""
|
||||
_tablename_ = "premium_guilds"
|
||||
_cache_ = {}
|
||||
|
||||
guildid = Integer(primary=True)
|
||||
premium_since = Timestamp()
|
||||
premium_until = Timestamp()
|
||||
custom_skin_id = Integer()
|
||||
|
||||
"""
|
||||
CREATE TABLE premium_guild_contributions(
|
||||
contributionid SERIAL PRIMARY KEY,
|
||||
userid BIGINT NOT NULL REFERENCES user_config,
|
||||
guildid BIGINT NOT NULL REFERENCES premium_guilds,
|
||||
transactionid INTEGER REFERENCES gem_transactions,
|
||||
duration INTEGER NOT NULL,
|
||||
_timestamp TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
"""
|
||||
premium_guild_contributions = Table('premium_guild_contributions')
|
||||
|
||||
19
src/modules/premium/errors.py
Normal file
19
src/modules/premium/errors.py
Normal file
@@ -0,0 +1,19 @@
|
||||
class GemTransactionFailed(Exception):
|
||||
"""
|
||||
Base exception class used when a gem transaction failed.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class BalanceTooLow(GemTransactionFailed):
|
||||
"""
|
||||
Exception raised when transaction results in a negative gem balance.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class BalanceTooHigh(GemTransactionFailed):
|
||||
"""
|
||||
Exception raised when transaction results in gem balance overflow.
|
||||
"""
|
||||
pass
|
||||
286
src/modules/premium/ui/premium.py
Normal file
286
src/modules/premium/ui/premium.py
Normal file
@@ -0,0 +1,286 @@
|
||||
from typing import Optional, TYPE_CHECKING, NamedTuple
|
||||
import asyncio
|
||||
import datetime as dt
|
||||
|
||||
import discord
|
||||
from discord.ui.button import button, Button, ButtonStyle
|
||||
from psycopg import sql
|
||||
|
||||
from meta import LionBot, conf
|
||||
from meta.logger import log_wrap
|
||||
from core.lion_user import LionUser
|
||||
from babel.translator import LazyStr
|
||||
from meta.errors import ResponseTimedOut, UserInputError
|
||||
from data import RawExpr
|
||||
from modules.premium.errors import BalanceTooLow
|
||||
|
||||
from utils.ui import MessageUI, Confirm, AButton
|
||||
from utils.lib import MessageArgs, utc_now
|
||||
|
||||
from .. import babel, logger
|
||||
from ..data import GemTransactionType, PremiumData
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..cog import PremiumCog
|
||||
|
||||
_p = babel._p
|
||||
|
||||
|
||||
class PremiumPlan(NamedTuple):
|
||||
text: LazyStr
|
||||
label: LazyStr
|
||||
emoji: Optional[discord.PartialEmoji | discord.Emoji | str]
|
||||
duration: int
|
||||
price: int
|
||||
|
||||
|
||||
plans = [
|
||||
PremiumPlan(
|
||||
_p('plan:three_months|text', "three months"),
|
||||
_p('plan:three_months|label', "Three Months"),
|
||||
None,
|
||||
90,
|
||||
4000
|
||||
),
|
||||
PremiumPlan(
|
||||
_p('plan:one_year|text', "one year"),
|
||||
_p('plan:one_year|label', "One Year"),
|
||||
None,
|
||||
365,
|
||||
12000
|
||||
),
|
||||
PremiumPlan(
|
||||
_p('plan:one_month|text', "one month"),
|
||||
_p('plan:one_month|label', "One Month"),
|
||||
None,
|
||||
30,
|
||||
1500
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class PremiumUI(MessageUI):
|
||||
def __init__(self, bot: LionBot, guild: discord.Guild, luser: LionUser, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
self.bot = bot
|
||||
self.guild = guild
|
||||
self.luser = luser
|
||||
|
||||
self.cog: 'PremiumCog' = bot.get_cog('PremiumCog') # type: ignore
|
||||
|
||||
# UI State
|
||||
self.premium_status: Optional[PremiumData.PremiumGuild] = None
|
||||
|
||||
self.plan_buttons = self._plan_buttons()
|
||||
self.link_button = self.cog.buy_gems_button()
|
||||
|
||||
# ----- API -----
|
||||
# ----- UI Components -----
|
||||
|
||||
async def plan_button(self, press: discord.Interaction, pressed: Button, plan: PremiumPlan):
|
||||
t = self.bot.translator.t
|
||||
|
||||
# Check Balance
|
||||
if self.luser.data.gems < plan.price:
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'ui:premium|button:plan|error:insufficient_gems',
|
||||
"You do not have enough LionGems to purchase this plan!"
|
||||
))
|
||||
)
|
||||
|
||||
# Confirm Purchase
|
||||
confirm_msg = t(_p(
|
||||
'ui:premium|button:plan|confirm|desc',
|
||||
"Contributing **{plan_text}** of premium subscription for this server"
|
||||
" will cost you {gem}**{plan_price}**.\n"
|
||||
"Are you sure you want to proceed?"
|
||||
)).format(
|
||||
plan_text=t(plan.text),
|
||||
gem=self.bot.config.emojis.gem,
|
||||
plan_price=plan.price,
|
||||
)
|
||||
confirm = Confirm(confirm_msg, press.user.id)
|
||||
confirm.embed.title = t(_p(
|
||||
'ui:premium|button:plan|confirm|title',
|
||||
"Confirm Server Upgrade"
|
||||
))
|
||||
confirm.embed.set_footer(
|
||||
text=t(_p(
|
||||
'ui:premium|button:plan|confirm|footer',
|
||||
"Your current balance is {balance} LionGems"
|
||||
)).format(balance=self.luser.data.gems)
|
||||
)
|
||||
confirm.embed.colour = 0x41f097
|
||||
|
||||
try:
|
||||
result = await confirm.ask(press, ephemeral=True)
|
||||
except ResponseTimedOut:
|
||||
result = False
|
||||
if not result:
|
||||
await press.followup.send(
|
||||
t(_p(
|
||||
'ui:premium|button:plan|confirm|cancelled',
|
||||
"Purchase cancelled! No LionGems were deducted from your account."
|
||||
)),
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
# Write transaction, plan contribution, and new plan status, with potential rollback
|
||||
try:
|
||||
await self._do_premium_upgrade(plan)
|
||||
except BalanceTooLow:
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'ui:premium|button:plan|error:insufficient_gems_post_confirm',
|
||||
"Insufficient LionGems to purchase this plan!"
|
||||
))
|
||||
)
|
||||
|
||||
# Acknowledge premium
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.brand_green(),
|
||||
title=t(_p(
|
||||
'ui:premium|button:plan|embed:success|title',
|
||||
"Server Upgraded!"
|
||||
)),
|
||||
description=t(_p(
|
||||
'ui:premium|button:plan|embed:success|desc',
|
||||
"You have contributed **{plan_text}** of premium subscription to this server!"
|
||||
)).format(plan_text=plan.text)
|
||||
)
|
||||
await press.followup.send(
|
||||
embed=embed
|
||||
)
|
||||
await self.refresh()
|
||||
|
||||
@log_wrap(action='premium upgrade')
|
||||
async def _do_premium_upgrade(self, plan: PremiumPlan):
|
||||
async with self.bot.db.connection() as conn:
|
||||
self.bot.db.conn = conn
|
||||
async with conn.transaction():
|
||||
# Perform the gem transaction
|
||||
transaction = await self.cog.gem_transaction(
|
||||
GemTransactionType.PURCHASE,
|
||||
actorid=self.luser.userid,
|
||||
from_account=self.luser.userid,
|
||||
to_account=None,
|
||||
amount=plan.price,
|
||||
description=(
|
||||
f"User purchased {plan.duration} days of premium"
|
||||
f" for guild {self.guild.id} using the `PremiumUI`."
|
||||
),
|
||||
note=None,
|
||||
reference=f"iid: {self._original.id if self._original else 'None'}"
|
||||
)
|
||||
|
||||
model = self.cog.data.PremiumGuild
|
||||
# Ensure the premium guild row exists
|
||||
premium_guild = await model.fetch_or_create(self.guild.id)
|
||||
|
||||
# Extend the subscription
|
||||
await model.table.update_where(guildid=self.guild.id).set(
|
||||
premium_until=RawExpr(
|
||||
sql.SQL("GREATEST(premium_until, now()) + {}").format(
|
||||
sql.Placeholder()
|
||||
),
|
||||
(dt.timedelta(days=plan.duration),)
|
||||
)
|
||||
)
|
||||
|
||||
# Finally, record the user's contribution
|
||||
await self.cog.data.premium_guild_contributions.insert(
|
||||
userid=self.luser.userid, guildid=self.guild.id,
|
||||
transactionid=transaction.transactionid, duration=plan.duration
|
||||
)
|
||||
|
||||
def _plan_buttons(self) -> list[Button]:
|
||||
"""
|
||||
Generate the Plan buttons.
|
||||
|
||||
Intended to be used once, upon initialisation.
|
||||
"""
|
||||
t = self.bot.translator.t
|
||||
buttons = []
|
||||
for plan in plans:
|
||||
butt = AButton(
|
||||
label=t(plan.label),
|
||||
emoji=plan.emoji,
|
||||
style=ButtonStyle.blurple,
|
||||
pass_kwargs={'plan': plan}
|
||||
)
|
||||
butt(self.plan_button)
|
||||
self.add_item(butt)
|
||||
buttons.append(butt)
|
||||
return buttons
|
||||
|
||||
# ----- UI Flow -----
|
||||
def _current_status(self) -> str:
|
||||
t = self.bot.translator.t
|
||||
|
||||
if self.premium_status is None or self.premium_status.premium_until is None:
|
||||
status = t(_p(
|
||||
'ui:premium|current_status:none',
|
||||
"__**Current Server Status:**__ Awaiting Upgrade."
|
||||
))
|
||||
elif self.premium_status.premium_until > utc_now():
|
||||
status = t(_p(
|
||||
'ui:premium|current_status:premium',
|
||||
"__**Current Server Status:**__ Upgraded! Premium until {expiry}"
|
||||
)).format(expiry=discord.utils.format_dt(self.premium_status.premium_until, 'd'))
|
||||
else:
|
||||
status = t(_p(
|
||||
'ui:premium|current_status:none',
|
||||
"__**Current Server Status:**__ Awaiting Upgrade. Premium status expired on {expiry}"
|
||||
)).format(expiry=discord.utils.format_dt(self.premium_status.premium_until, 'd'))
|
||||
|
||||
return status
|
||||
|
||||
async def make_message(self) -> MessageArgs:
|
||||
t = self.bot.translator.t
|
||||
|
||||
blurb = t(_p(
|
||||
'ui:premium|embed|description',
|
||||
"By supporting our project, you will get access to countless customisation features!\n\n"
|
||||
"- **Rebranding:** Customizable HEX colours and"
|
||||
" **beautiful premium skins** for all of your community members!\n"
|
||||
"- **Remove the vote and sponsor prompt!**\n"
|
||||
"- Access to all of the [future premium features](https://staging.lionbot.org/donate)\n\n"
|
||||
"Both server owners and **regular users** can"
|
||||
" **buy and gift a subscription for this server** using this command!\n"
|
||||
"To support both Leo and your server, **use the buttons below**!"
|
||||
)) + '\n\n' + self._current_status()
|
||||
|
||||
embed = discord.Embed(
|
||||
colour=0x41f097,
|
||||
title=t(_p(
|
||||
'ui:premium|embed|title',
|
||||
"Support Leo and Upgrade your Server!"
|
||||
)),
|
||||
description=blurb,
|
||||
)
|
||||
embed.set_thumbnail(
|
||||
url="https://i.imgur.com/v1mZolL.png"
|
||||
)
|
||||
embed.set_image(
|
||||
url="https://cdn.discordapp.com/attachments/824196406482305034/972405513570615326/premium_test.png"
|
||||
)
|
||||
embed.set_footer(
|
||||
text=t(_p(
|
||||
'ui:premium|embed|footer',
|
||||
"Your current balance is {balance} LionGems."
|
||||
)).format(balance=self.luser.data.gems)
|
||||
)
|
||||
|
||||
return MessageArgs(embed=embed)
|
||||
|
||||
async def refresh_layout(self):
|
||||
self.set_layout(
|
||||
(*self.plan_buttons, self.link_button),
|
||||
)
|
||||
|
||||
async def reload(self):
|
||||
self.premium_status = await self.cog.data.PremiumGuild.fetch(self.guild.id, cached=False)
|
||||
await self.luser.data.refresh()
|
||||
198
src/modules/premium/ui/transactions.py
Normal file
198
src/modules/premium/ui/transactions.py
Normal file
@@ -0,0 +1,198 @@
|
||||
from typing import Optional
|
||||
import asyncio
|
||||
import datetime as dt
|
||||
|
||||
import discord
|
||||
from discord.ui.button import button, Button, ButtonStyle
|
||||
|
||||
from meta import LionBot, conf
|
||||
from data import ORDER
|
||||
|
||||
from utils.ui import MessageUI, input
|
||||
from utils.lib import MessageArgs, tabulate
|
||||
|
||||
from .. import babel, logger
|
||||
from ..data import PremiumData
|
||||
|
||||
_p = babel._p
|
||||
|
||||
|
||||
class TransactionList(MessageUI):
|
||||
block_len = 5
|
||||
|
||||
def __init__(self, bot: LionBot, userid: int, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
self.bot = bot
|
||||
self.userid = userid
|
||||
|
||||
self._pagen = 0
|
||||
self.blocks: list[list[PremiumData.GemTransaction]] = [[]]
|
||||
|
||||
@property
|
||||
def page_count(self):
|
||||
return len(self.blocks)
|
||||
|
||||
@property
|
||||
def pagen(self):
|
||||
self._pagen = self._pagen % self.page_count
|
||||
return self._pagen
|
||||
|
||||
@pagen.setter
|
||||
def pagen(self, value):
|
||||
self._pagen = value % self.page_count
|
||||
|
||||
@property
|
||||
def current_page(self):
|
||||
return self.blocks[self.pagen]
|
||||
|
||||
# ----- UI Components -----
|
||||
|
||||
# Backwards
|
||||
@button(emoji=conf.emojis.backward, style=ButtonStyle.grey)
|
||||
async def prev_button(self, press: discord.Interaction, pressed: Button):
|
||||
await press.response.defer(thinking=True, ephemeral=True)
|
||||
self.pagen -= 1
|
||||
await self.refresh(thinking=press)
|
||||
|
||||
# Jump to page
|
||||
@button(label="JUMP_PLACEHOLDER", style=ButtonStyle.blurple)
|
||||
async def jump_button(self, press: discord.Interaction, pressed: Button):
|
||||
"""
|
||||
Jump-to-page button.
|
||||
Loads a page-switch dialogue.
|
||||
"""
|
||||
t = self.bot.translator.t
|
||||
try:
|
||||
interaction, value = await input(
|
||||
press,
|
||||
title=t(_p(
|
||||
'ui:transactions|button:jump|input:title',
|
||||
"Jump to page"
|
||||
)),
|
||||
question=t(_p(
|
||||
'ui:transactions|button:jump|input:question',
|
||||
"Page number to jump to"
|
||||
))
|
||||
)
|
||||
value = value.strip()
|
||||
except asyncio.TimeoutError:
|
||||
return
|
||||
|
||||
if not value.lstrip('- ').isdigit():
|
||||
error_embed = discord.Embed(
|
||||
title=t(_p(
|
||||
'ui:transactions|button:jump|error:invalid_page',
|
||||
"Invalid page number, please try again!"
|
||||
)),
|
||||
colour=discord.Colour.brand_red()
|
||||
)
|
||||
await interaction.response.send_message(embed=error_embed, ephemeral=True)
|
||||
else:
|
||||
await interaction.response.defer(thinking=True)
|
||||
pagen = int(value.lstrip('- '))
|
||||
if value.startswith('-'):
|
||||
pagen = -1 * pagen
|
||||
elif pagen > 0:
|
||||
pagen = pagen - 1
|
||||
self.pagen = pagen
|
||||
await self.refresh(thinking=interaction)
|
||||
|
||||
async def jump_button_refresh(self):
|
||||
component = self.jump_button
|
||||
component.label = f"{self.pagen + 1}/{self.page_count}"
|
||||
component.disabled = (self.page_count <= 1)
|
||||
|
||||
# Forward
|
||||
@button(emoji=conf.emojis.forward, style=ButtonStyle.grey)
|
||||
async def next_button(self, press: discord.Interaction, pressed: Button):
|
||||
await press.response.defer(thinking=True)
|
||||
self.pagen += 1
|
||||
await self.refresh(thinking=press)
|
||||
|
||||
# Quit
|
||||
@button(emoji=conf.emojis.cancel, style=ButtonStyle.red)
|
||||
async def quit_button(self, press: discord.Interaction, pressed: Button):
|
||||
"""
|
||||
Quit the UI.
|
||||
"""
|
||||
await press.response.defer()
|
||||
await self.quit()
|
||||
|
||||
# ----- UI Flow -----
|
||||
async def make_message(self) -> MessageArgs:
|
||||
t = self.bot.translator.t
|
||||
|
||||
title = t(_p(
|
||||
'ui:transactions|embed|title',
|
||||
"Gem Transactions for user `{userid}`"
|
||||
)).format(userid=self.userid)
|
||||
|
||||
rows = self.current_page
|
||||
if rows:
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
title=title,
|
||||
description=t(_p(
|
||||
'ui:transactions|embed|desc:balance',
|
||||
"User {target} has a LionGem balance of {gem}**{balance}**"
|
||||
)).format(
|
||||
gem=self.bot.config.emojis.gem,
|
||||
target=f"<@{self.userid}>",
|
||||
balance=await (self.bot.get_cog('PremiumCog')).get_gem_balance(self.userid),
|
||||
)
|
||||
)
|
||||
for row in rows:
|
||||
name = f"Transaction #{row.transactionid}"
|
||||
table_rows = (
|
||||
('timestamp', discord.utils.format_dt(row._timestamp)),
|
||||
('type', row.transaction_type.name),
|
||||
('amount', str(row.amount)),
|
||||
('actor', f"<@{row.actorid}>"),
|
||||
('from', f"`{row.from_account}`" if row.from_account else 'None'),
|
||||
('to', f"`{row.to_account}`" if row.to_account else 'None'),
|
||||
('reference', str(row.reference)),
|
||||
)
|
||||
table = '\n'.join(tabulate(*table_rows))
|
||||
embed.add_field(
|
||||
name=name,
|
||||
value=f"{row.description}\n{table}",
|
||||
inline=False
|
||||
)
|
||||
else:
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.brand_red(),
|
||||
description = t(_p(
|
||||
'ui:transactions|embed|desc:no_transactions',
|
||||
"This user has no related gem transactions!"
|
||||
))
|
||||
)
|
||||
return MessageArgs(embed=embed)
|
||||
|
||||
async def refresh_layout(self):
|
||||
to_refresh = (
|
||||
self.jump_button_refresh(),
|
||||
)
|
||||
await asyncio.gather(*to_refresh)
|
||||
|
||||
if self.page_count > 1:
|
||||
self.set_layout(
|
||||
(self.prev_button, self.jump_button, self.quit_button, self.next_button),
|
||||
)
|
||||
else:
|
||||
self.set_layout(
|
||||
(self.quit_button,)
|
||||
)
|
||||
|
||||
async def reload(self):
|
||||
model = PremiumData.GemTransaction
|
||||
|
||||
rows = await model.fetch_where(
|
||||
(model.from_account == self.userid) | (model.to_account == self.userid)
|
||||
).order_by('_timestamp', ORDER.DESC)
|
||||
|
||||
blocks = [
|
||||
rows[i:i+self.block_len]
|
||||
for i in range(0, len(rows), self.block_len)
|
||||
]
|
||||
self.blocks = blocks or [[]]
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
303
src/modules/ranks/ui/templates.py
Normal file
303
src/modules/ranks/ui/templates.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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!*"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
10
src/modules/skins/__init__.py
Normal file
10
src/modules/skins/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
import logging
|
||||
from babel.translator import LocalBabel
|
||||
|
||||
babel = LocalBabel('customskins')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def setup(bot):
|
||||
from .cog import CustomSkinCog
|
||||
await bot.add_cog(CustomSkinCog(bot))
|
||||
388
src/modules/skins/cog.py
Normal file
388
src/modules/skins/cog.py
Normal file
@@ -0,0 +1,388 @@
|
||||
from typing import Optional
|
||||
import asyncio
|
||||
|
||||
import discord
|
||||
from discord.ext import commands as cmds
|
||||
import discord.app_commands as appcmds
|
||||
from cachetools import LRUCache
|
||||
from bidict import bidict
|
||||
from frozendict import frozendict
|
||||
|
||||
|
||||
from meta import LionCog, LionBot, LionContext
|
||||
from meta.errors import UserInputError
|
||||
from meta.logger import log_wrap
|
||||
from utils.lib import MISSING, utc_now
|
||||
from wards import sys_admin_ward, low_management_ward
|
||||
from gui.base import AppSkin
|
||||
from babel.translator import ctx_locale
|
||||
|
||||
from . import logger, babel
|
||||
from .data import CustomSkinData
|
||||
from .skinlib import appskin_as_choice, FrozenCustomSkin, CustomSkin
|
||||
from .settings import GlobalSkinSettings
|
||||
from .settingui import GlobalSkinSettingUI
|
||||
from .userskinui import UserSkinUI
|
||||
from .editor.skineditor import CustomSkinEditor
|
||||
|
||||
_p = babel._p
|
||||
|
||||
|
||||
class CustomSkinCog(LionCog):
|
||||
def __init__(self, bot: LionBot):
|
||||
self.bot = bot
|
||||
self.data: CustomSkinData = bot.db.load_registry(CustomSkinData())
|
||||
self.bot_settings = GlobalSkinSettings()
|
||||
|
||||
# Cache of app skin id -> app skin name
|
||||
# After initialisation, contains all the base skins available for this app
|
||||
self.appskin_names: bidict[int, str] = bidict()
|
||||
|
||||
# Bijective cache of skin property ids <-> (card_id, property_name) tuples
|
||||
self.skin_properties: bidict[int, tuple[str, str]] = bidict()
|
||||
|
||||
# Cache of currently active user skins
|
||||
# Invalidation handled by local event handler
|
||||
self.active_user_skinids: LRUCache[int, Optional[int]] = LRUCache(maxsize=5000)
|
||||
|
||||
# Cache of custom skin id -> frozen custom skin
|
||||
self.custom_skins: LRUCache[int, FrozenCustomSkin] = LRUCache(maxsize=1000)
|
||||
|
||||
self.current_default: Optional[str] = None
|
||||
|
||||
async def cog_load(self):
|
||||
await self.data.init()
|
||||
|
||||
if (leo_setting_cog := self.bot.get_cog('LeoSettings')) is not None:
|
||||
leo_setting_cog.bot_setting_groups.append(self.bot_settings)
|
||||
self.crossload_group(self.leo_group, leo_setting_cog.leo_group)
|
||||
|
||||
if (config_cog := self.bot.get_cog('ConfigCog')) is not None:
|
||||
self.crossload_group(self.admin_group, config_cog.admin_group)
|
||||
|
||||
if (user_cog := self.bot.get_cog('UserConfigCog')) is not None:
|
||||
self.crossload_group(self.my_group, user_cog.userconfig_group)
|
||||
|
||||
await self._reload_appskins()
|
||||
await self._reload_property_map()
|
||||
await self.get_default_skin()
|
||||
|
||||
async def _reload_property_map(self):
|
||||
"""
|
||||
Reload the skin property id to (card_id, property_name) bijection.
|
||||
"""
|
||||
records = await self.data.skin_property_map.select_where()
|
||||
cache = self.skin_properties
|
||||
|
||||
cache.clear()
|
||||
for record in records:
|
||||
cache[record['property_id']] = (record['card_id'], record['property_name'])
|
||||
|
||||
logger.info(
|
||||
f"Loaded '{len(cache)}' custom skin properties."
|
||||
)
|
||||
|
||||
async def _reload_appskins(self):
|
||||
"""
|
||||
Reload the global_available_skin id to the appskin name.
|
||||
Create global_available_skins that don't already exist.
|
||||
"""
|
||||
cache = self.appskin_names
|
||||
available = list(AppSkin.skins_data['skin_map'].keys())
|
||||
rows = await self.data.GlobalSkin.fetch_where(skin_name=available)
|
||||
|
||||
cache.clear()
|
||||
for row in rows:
|
||||
cache[row.skin_id] = row.skin_name
|
||||
|
||||
# Not caring about efficiency here because this essentially needs to happen once ever
|
||||
missing = [name for name in available if name not in cache.values()]
|
||||
for name in missing:
|
||||
row = await self.data.GlobalSkin.create(skin_name=name)
|
||||
cache[row.skin_id] = row.skin_name
|
||||
|
||||
logger.info(
|
||||
f"Loaded '{len(cache)}' global base skins."
|
||||
)
|
||||
|
||||
# ----- Internal API -----
|
||||
def get_base(self, base_skin_id: int) -> AppSkin:
|
||||
"""
|
||||
Initialise a localised AppSkin for the given base skin id.
|
||||
"""
|
||||
if base_skin_id not in self.appskin_names:
|
||||
raise ValueError(f"Unknown app skin id '{base_skin_id}'")
|
||||
|
||||
return AppSkin.get(
|
||||
skin_id=self.appskin_names[base_skin_id],
|
||||
locale=ctx_locale.get(),
|
||||
use_cache=True,
|
||||
)
|
||||
|
||||
async def get_default_skin(self) -> Optional[str]:
|
||||
"""
|
||||
Get the current app-default skin, and return it as a skin name.
|
||||
|
||||
May be None if there is no app-default set.
|
||||
This should almost always hit cache.
|
||||
"""
|
||||
setting = self.bot_settings.DefaultSkin
|
||||
instance = await setting.get(self.bot.appname)
|
||||
self.current_default = instance.value
|
||||
return instance.value
|
||||
|
||||
async def fetch_property_ids(self, *card_properties: tuple[str, str]) -> list[int]:
|
||||
"""
|
||||
Fetch the skin property ids for the given (card_id, property_name) tuples.
|
||||
|
||||
Creates any missing properties.
|
||||
"""
|
||||
mapper = self.skin_properties.inverse
|
||||
missing = [prop for prop in card_properties if prop not in mapper]
|
||||
if missing:
|
||||
# First insert missing properties
|
||||
await self.data.skin_property_map.insert_many(
|
||||
('card_id', 'property_name'),
|
||||
*missing
|
||||
)
|
||||
await self._reload_property_map()
|
||||
return [mapper[prop] for prop in card_properties]
|
||||
|
||||
async def get_guild_skinid(self, guildid: int) -> Optional[int]:
|
||||
"""
|
||||
Fetch the custom_skin_id associated to the current guild.
|
||||
|
||||
Returns None if the guild is not premium or has no custom skin set.
|
||||
Usually hits cache (Specifically the PremiumGuild cache).
|
||||
"""
|
||||
cog = self.bot.get_cog('PremiumCog')
|
||||
if not cog:
|
||||
logger.error(
|
||||
"Trying to get guild skinid without loaded premium cog!"
|
||||
)
|
||||
return None
|
||||
row = await cog.data.PremiumGuild.fetch(guildid)
|
||||
return row.custom_skin_id if row else None
|
||||
|
||||
async def get_user_skinid(self, userid: int) -> Optional[int]:
|
||||
"""
|
||||
Fetch the custom_skin_id of the active skin in the given user's skin inventory.
|
||||
|
||||
Returns None if the user does not have an active skin.
|
||||
Should usually be cached by `self.active_user_skinids`.
|
||||
"""
|
||||
skinid = self.active_user_skinids.get(userid, MISSING)
|
||||
if skinid is MISSING:
|
||||
rows = await self.data.UserSkin.fetch_where(userid=userid, active=True)
|
||||
skinid = rows[0].custom_skin_id if rows else None
|
||||
self.active_user_skinids[userid] = skinid
|
||||
return skinid
|
||||
|
||||
async def args_for_skin(self, skinid: int, cardid: str) -> dict[str, str]:
|
||||
"""
|
||||
Fetch the skin argument dictionary for the given custom_skin_id.
|
||||
|
||||
Should usually be cached by `self.custom_skin_args`.
|
||||
"""
|
||||
skin = self.custom_skins.get(skinid, None)
|
||||
if skin is None:
|
||||
custom_skin = await CustomSkin.fetch(self.bot, skinid)
|
||||
skin = custom_skin.freeze()
|
||||
self.custom_skins[skinid] = skin
|
||||
return skin.args_for(cardid)
|
||||
|
||||
# ----- External API -----
|
||||
async def get_skinargs_for(self,
|
||||
guildid: Optional[int], userid: Optional[int], card_id: str
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Get skin arguments for a standard GUI render with the given guild, user, and for the given card.
|
||||
|
||||
Takes into account the global defaults, guild custom skin, and user active skin.
|
||||
"""
|
||||
args = {}
|
||||
|
||||
if userid and (skinid := await self.get_user_skinid(userid)):
|
||||
skin_args = await self.args_for_skin(skinid, card_id)
|
||||
args.update(skin_args)
|
||||
elif guildid and (skinid := await self.get_guild_skinid(guildid)):
|
||||
skin_args = await self.args_for_skin(skinid, card_id)
|
||||
args.update(skin_args)
|
||||
|
||||
default = self.current_default
|
||||
if default:
|
||||
args.setdefault("base_skin_id", default)
|
||||
|
||||
return args
|
||||
|
||||
# ----- Event Handlers -----
|
||||
@LionCog.listener('on_userset_skin')
|
||||
async def refresh_user_skin(self, userid: int):
|
||||
"""
|
||||
Update cached user active skinid.
|
||||
"""
|
||||
self.active_user_skinids.pop(userid, None)
|
||||
await self.get_user_skinid(userid)
|
||||
|
||||
@LionCog.listener('on_skin_updated')
|
||||
async def refresh_custom_skin(self, skinid: int):
|
||||
"""
|
||||
Update cached args for given custom skin id.
|
||||
"""
|
||||
self.custom_skins.pop(skinid, None)
|
||||
custom_skin = await CustomSkin.fetch(self.bot, skinid)
|
||||
if custom_skin is not None:
|
||||
skin = custom_skin.freeze()
|
||||
self.custom_skins[skinid] = skin
|
||||
|
||||
@LionCog.listener('on_botset_skin')
|
||||
async def handle_botset_skin(self, appname, instance):
|
||||
await self.bot.global_dispatch('global_botset_skin', appname)
|
||||
|
||||
@LionCog.listener('on_global_botset_skin')
|
||||
async def refresh_default_skin(self, appname):
|
||||
await self.bot.core.data.BotConfig.fetch(appname, cached=False)
|
||||
await self.get_default_skin()
|
||||
|
||||
# ----- Userspace commands -----
|
||||
@LionCog.placeholder_group
|
||||
@cmds.hybrid_group("my", with_app_command=False)
|
||||
async def my_group(self, ctx: LionContext):
|
||||
...
|
||||
|
||||
@my_group.command(
|
||||
name=_p('cmd:my_skin', "skin"),
|
||||
description=_p(
|
||||
'cmd:my_skin|desc',
|
||||
"Change the colours of your interface"
|
||||
)
|
||||
)
|
||||
async def cmd_my_skin(self, ctx: LionContext):
|
||||
if not ctx.interaction:
|
||||
return
|
||||
ui = UserSkinUI(self.bot, ctx.author.id, ctx.author.id)
|
||||
await ui.run(ctx.interaction, ephemeral=True)
|
||||
await ui.wait()
|
||||
|
||||
# ----- Adminspace commands -----
|
||||
@LionCog.placeholder_group
|
||||
@cmds.hybrid_group("admin", with_app_command=False)
|
||||
async def admin_group(self, ctx: LionContext):
|
||||
...
|
||||
|
||||
@admin_group.command(
|
||||
name=_p('cmd:admin_brand', "brand"),
|
||||
description=_p(
|
||||
'cmd:admin_brand|desc',
|
||||
"Fully customise my default interface for your members!"
|
||||
)
|
||||
)
|
||||
@low_management_ward
|
||||
async def cmd_admin_brand(self, ctx: LionContext):
|
||||
if not ctx.interaction:
|
||||
return
|
||||
if not ctx.guild:
|
||||
return
|
||||
t = self.bot.translator.t
|
||||
|
||||
# Check guild premium status
|
||||
premiumcog = self.bot.get_cog('PremiumCog')
|
||||
guild_row = await premiumcog.data.PremiumGuild.fetch(ctx.guild.id, cached=False)
|
||||
|
||||
if not guild_row:
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'cmd:admin_brand|error:not_premium',
|
||||
"Only premium servers can modify their interface theme! "
|
||||
"Use the {premium} command to upgrade your server."
|
||||
)).format(premium=self.bot.core.mention_cmd('premium'))
|
||||
)
|
||||
|
||||
await ctx.interaction.response.defer(thinking=True, ephemeral=False)
|
||||
|
||||
if guild_row.custom_skin_id is None:
|
||||
# Create new custom skin
|
||||
skin_data = await self.data.CustomisedSkin.create(
|
||||
base_skin_id=self.appskin_names.inverse[self.current_default] if self.current_default else None
|
||||
)
|
||||
await guild_row.update(custom_skin_id=skin_data.custom_skin_id)
|
||||
|
||||
skinid = guild_row.custom_skin_id
|
||||
custom_skin = await CustomSkin.fetch(self.bot, skinid)
|
||||
if custom_skin is None:
|
||||
raise ValueError("Invalid custom skin id")
|
||||
|
||||
# Open the CustomSkinEditor with this skin
|
||||
ui = CustomSkinEditor(custom_skin, callerid=ctx.author.id)
|
||||
await ui.send(ctx.channel)
|
||||
await ctx.interaction.delete_original_response()
|
||||
await ui.wait()
|
||||
|
||||
# ----- Owner commands -----
|
||||
@LionCog.placeholder_group
|
||||
@cmds.hybrid_group("leo", with_app_command=False)
|
||||
async def leo_group(self, ctx: LionContext):
|
||||
...
|
||||
|
||||
@leo_group.command(
|
||||
name=_p('cmd:leo_skin', "skin"),
|
||||
description=_p(
|
||||
'cmd:leo_skin|desc',
|
||||
"View and update the global skin settings"
|
||||
)
|
||||
)
|
||||
@appcmds.rename(
|
||||
default_skin=_p('cmd:leo_skin|param:default_skin', "default_skin"),
|
||||
)
|
||||
@appcmds.describe(
|
||||
default_skin=_p(
|
||||
'cmd:leo_skin|param:default_skin|desc',
|
||||
"Set the global default skin."
|
||||
)
|
||||
)
|
||||
@sys_admin_ward
|
||||
async def cmd_leo_skin(self, ctx: LionContext,
|
||||
default_skin: Optional[str] = None):
|
||||
if not ctx.interaction:
|
||||
return
|
||||
|
||||
await ctx.interaction.response.defer(thinking=True)
|
||||
modified = []
|
||||
|
||||
if default_skin is not None:
|
||||
setting = self.bot_settings.DefaultSkin
|
||||
instance = await setting.from_string(self.bot.appname, default_skin)
|
||||
modified.append(instance)
|
||||
|
||||
for instance in modified:
|
||||
await instance.write()
|
||||
|
||||
# No update_str, just show the config window
|
||||
ui = GlobalSkinSettingUI(self.bot, self.bot.appname, ctx.channel.id)
|
||||
await ui.run(ctx.interaction)
|
||||
await ui.wait()
|
||||
|
||||
|
||||
@cmd_leo_skin.autocomplete('default_skin')
|
||||
async def cmd_leo_skin_acmpl_default_skin(self, interaction: discord.Interaction, partial: str):
|
||||
babel = self.bot.get_cog('BabelCog')
|
||||
ctx_locale.set(await babel.get_user_locale(interaction.user.id))
|
||||
|
||||
choices = []
|
||||
for skinid in self.appskin_names:
|
||||
appskin = self.get_base(skinid)
|
||||
match = partial.lower()
|
||||
if match in appskin.skin_id.lower() or match in appskin.display_name.lower():
|
||||
choices.append(appskin_as_choice(appskin))
|
||||
if not choices:
|
||||
t = self.bot.translator.t
|
||||
choices = [
|
||||
appcmds.Choice(
|
||||
name=t(_p(
|
||||
'cmd:leo_skin|acmpl:default_skin|error:no_match',
|
||||
"No app skins matching {partial}"
|
||||
)).format(partial=partial)[:100],
|
||||
value=partial
|
||||
)
|
||||
]
|
||||
return choices
|
||||
117
src/modules/skins/data.py
Normal file
117
src/modules/skins/data.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from data import Registry, RowModel, Table
|
||||
from data.columns import Integer, Bool, Timestamp, String
|
||||
|
||||
|
||||
class CustomSkinData(Registry):
|
||||
class GlobalSkin(RowModel):
|
||||
"""
|
||||
Schema
|
||||
------
|
||||
CREATE TABLE global_available_skins(
|
||||
skin_id SERIAL PRIMARY KEY,
|
||||
skin_name TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX global_available_skin_names ON global_available_skins (skin_name);
|
||||
"""
|
||||
_tablename_ = 'global_available_skins'
|
||||
_cache_ = {}
|
||||
|
||||
skin_id = Integer(primary=True)
|
||||
skin_name = String()
|
||||
|
||||
class CustomisedSkin(RowModel):
|
||||
"""
|
||||
Schema
|
||||
------
|
||||
CREATE TABLE customised_skins(
|
||||
custom_skin_id SERIAL PRIMARY KEY,
|
||||
base_skin_id INTEGER REFERENCES global_available_skins (skin_id),
|
||||
_timestamp TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
"""
|
||||
_tablename_ = 'customised_skins'
|
||||
|
||||
custom_skin_id = Integer(primary=True)
|
||||
base_skin_id = Integer()
|
||||
|
||||
_timestamp = Timestamp()
|
||||
|
||||
"""
|
||||
Schema
|
||||
------
|
||||
CREATE TABLE customised_skin_property_ids(
|
||||
property_id SERIAL PRIMARY KEY,
|
||||
card_id TEXT NOT NULL,
|
||||
property_name TEXT NOT NULL,
|
||||
UNIQUE(card_id, property_name)
|
||||
);
|
||||
"""
|
||||
skin_property_map = Table('customised_skin_property_ids')
|
||||
|
||||
"""
|
||||
Schema
|
||||
------
|
||||
CREATE TABLE customised_skin_properties(
|
||||
custom_skin_id INTEGER NOT NULL REFERENCES customised_skins (custom_skin_id),
|
||||
property_id INTEGER NOT NULL REFERENCES customised_skin_property_ids (property_id),
|
||||
value TEXT NOT NULL,
|
||||
PRIMARY KEY (custom_skin_id, property_id)
|
||||
);
|
||||
CREATE INDEX customised_skin_property_skin_id ON customised_skin_properties(custom_skin_id);
|
||||
"""
|
||||
skin_properties = Table('customised_skin_properties')
|
||||
|
||||
"""
|
||||
Schema
|
||||
------
|
||||
CREATE VIEW customised_skin_data AS
|
||||
SELECT
|
||||
skins.custom_skin_id AS custom_skin_id,
|
||||
skins.base_skin_id AS base_skin_id,
|
||||
properties.property_id AS property_id,
|
||||
prop_ids.card_id AS card_id,
|
||||
prop_ids.property_name AS property_name,
|
||||
properties.value AS value
|
||||
FROM
|
||||
customised_skins skins
|
||||
LEFT JOIN customised_skin_properties properties ON skins.custom_skin_id = properties.custom_skin_id
|
||||
LEFT JOIN customised_skin_property_ids prop_ids ON properties.property_id = prop_ids.property_id;
|
||||
"""
|
||||
custom_skin_info = Table('customised_skin_data')
|
||||
|
||||
class UserSkin(RowModel):
|
||||
"""
|
||||
Schema
|
||||
------
|
||||
CREATE TABLE user_skin_inventory(
|
||||
itemid SERIAL PRIMARY KEY,
|
||||
userid BIGINT NOT NULL REFERENCES user_config (userid) ON DELETE CASCADE,
|
||||
custom_skin_id INTEGER NOT NULL REFERENCES customised_skins (custom_skin_id) ON DELETE CASCADE,
|
||||
transactionid INTEGER REFERENCES gem_transactions (transactionid),
|
||||
active BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
acquired_at TIMESTAMPTZ DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX user_skin_inventory_users ON user_skin_inventory(userid);
|
||||
CREATE UNIQUE INDEX user_skin_inventory_active ON user_skin_inventory(userid) WHERE active = TRUE;
|
||||
"""
|
||||
_tablename_ = 'user_skin_inventory'
|
||||
|
||||
itemid = Integer(primary=True)
|
||||
userid = Integer()
|
||||
custom_skin_id = Integer()
|
||||
transactionid = Integer()
|
||||
active = Bool()
|
||||
acquired_at = Timestamp()
|
||||
expires_at = Timestamp()
|
||||
|
||||
"""
|
||||
Schema
|
||||
------
|
||||
CREATE VIEW user_active_skins AS
|
||||
SELECT
|
||||
*
|
||||
FROM user_skin_inventory
|
||||
WHERE active=True;
|
||||
"""
|
||||
user_active_skins = Table('user_active_skins')
|
||||
0
src/modules/skins/editor/__init__.py
Normal file
0
src/modules/skins/editor/__init__.py
Normal file
133
src/modules/skins/editor/layout.py
Normal file
133
src/modules/skins/editor/layout.py
Normal file
@@ -0,0 +1,133 @@
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import uuid
|
||||
import discord
|
||||
from discord.components import SelectOption
|
||||
|
||||
from babel.translator import LazyStr
|
||||
from gui.base.Card import Card
|
||||
from utils.lib import EmbedField, tabulate
|
||||
|
||||
from .skinsetting import SettingInputType, Setting
|
||||
from ..skinlib import CustomSkin
|
||||
|
||||
|
||||
@dataclass
|
||||
class SettingGroup:
|
||||
"""
|
||||
Data class representing a collection of settings which are naturally
|
||||
grouped together at interface level.
|
||||
|
||||
Typically the settings in a single SettingGroup are displayed
|
||||
in the same embed field, the settings are edited with the same modal,
|
||||
and the group represents a single option in the "setting group menu".
|
||||
|
||||
Setting groups do not correspond to any grouping at the Card or Skin level,
|
||||
and may cross multiple cards.
|
||||
"""
|
||||
|
||||
# The name and description strings are shown in the embed field and menu option
|
||||
name: LazyStr
|
||||
|
||||
# Tuple of settings that are part of this setting group
|
||||
settings: tuple[Setting, ...]
|
||||
|
||||
description: Optional[LazyStr] = None
|
||||
|
||||
# Whether the group should be displayed in a group or not
|
||||
ungrouped: bool = False
|
||||
|
||||
# Whether the embed field should be inline
|
||||
inline: bool = True
|
||||
|
||||
# Component custom id to identify the editing component
|
||||
# Also used as the value of the select option
|
||||
custom_id: str = str(uuid.uuid4())
|
||||
|
||||
@property
|
||||
def editable_settings(self):
|
||||
return tuple(setting for setting in self.settings if setting.input_type is SettingInputType.ModalInput)
|
||||
|
||||
def embed_field_for(self, skin: CustomSkin) -> EmbedField:
|
||||
"""
|
||||
Tabulates the contained settings and builds an embed field for the editor UI.
|
||||
"""
|
||||
t = skin.bot.translator.t
|
||||
|
||||
rows: list[tuple[str, str]] = []
|
||||
for setting in self.settings:
|
||||
name = t(setting.display_name)
|
||||
value = setting.value_in(skin) or setting.default_value_in(skin)
|
||||
formatted = setting.format_value_in(skin, value)
|
||||
rows.append((name, formatted))
|
||||
|
||||
lines = tabulate(*rows)
|
||||
table = '\n'.join(lines)
|
||||
|
||||
description = f"*{t(self.description)}*" if self.description else ''
|
||||
|
||||
embed_field = EmbedField(
|
||||
name=t(self.name),
|
||||
value=f"{description}\n{table}",
|
||||
inline=self.inline,
|
||||
)
|
||||
return embed_field
|
||||
|
||||
def select_option_for(self, skin: CustomSkin) -> SelectOption:
|
||||
"""
|
||||
Makes a SelectOption referring to this setting group.
|
||||
"""
|
||||
t = skin.bot.translator.t
|
||||
option = SelectOption(
|
||||
label=t(self.name),
|
||||
description=t(self.description) if self.description else None,
|
||||
value=self.custom_id,
|
||||
)
|
||||
return option
|
||||
|
||||
|
||||
@dataclass
|
||||
class Page:
|
||||
"""
|
||||
Represents a page of skin settings for the skin editor UI.
|
||||
"""
|
||||
# Various string attributes of the page
|
||||
display_name: LazyStr
|
||||
editing_description: Optional[LazyStr] = None
|
||||
preview_description: Optional[LazyStr] = None
|
||||
|
||||
visible_in_preview: bool = True
|
||||
render_card: Optional[type[Card]] = None
|
||||
|
||||
groups: list[SettingGroup] = field(default_factory=list)
|
||||
|
||||
def make_embed_for(self, skin: CustomSkin) -> discord.Embed:
|
||||
t = skin.bot.translator.t
|
||||
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
title=t(self.display_name),
|
||||
)
|
||||
|
||||
description_lines: list[str] = []
|
||||
field_counter = 0
|
||||
|
||||
for group in self.groups:
|
||||
field = group.embed_field_for(skin)
|
||||
if group.ungrouped:
|
||||
description_lines.append(field.value)
|
||||
else:
|
||||
embed.add_field(**field._asdict())
|
||||
if not (field_counter) % 3:
|
||||
embed.add_field(name='', value='')
|
||||
field_counter += 1
|
||||
field_counter += 1
|
||||
|
||||
if description_lines:
|
||||
embed.description = '\n'.join(description_lines)
|
||||
|
||||
if self.render_card is not None:
|
||||
embed.set_image(url='attachment://sample.png')
|
||||
|
||||
return embed
|
||||
16
src/modules/skins/editor/pages/__init__.py
Normal file
16
src/modules/skins/editor/pages/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from .stats import stats_page
|
||||
from .profile import profile_page
|
||||
from .summary import summary_page
|
||||
from .weekly import weekly_page
|
||||
from .monthly import monthly_page
|
||||
from .weekly_goals import weekly_goal_page
|
||||
from .monthly_goals import monthly_goal_page
|
||||
from .leaderboard import leaderboard_page
|
||||
|
||||
|
||||
pages = [
|
||||
profile_page, stats_page,
|
||||
weekly_page, monthly_page,
|
||||
weekly_goal_page, monthly_goal_page,
|
||||
leaderboard_page,
|
||||
]
|
||||
294
src/modules/skins/editor/pages/leaderboard.py
Normal file
294
src/modules/skins/editor/pages/leaderboard.py
Normal file
@@ -0,0 +1,294 @@
|
||||
from gui.cards import LeaderboardCard
|
||||
|
||||
from ... import babel
|
||||
from ..skinsetting import ColourSetting, SkinSetting, ColoursSetting
|
||||
from ..layout import Page, SettingGroup
|
||||
|
||||
_p = babel._p
|
||||
|
||||
"""
|
||||
top_position_colour
|
||||
top_name_colour
|
||||
top_hours_colour
|
||||
|
||||
entry_position_colour
|
||||
entry_position_highlight_colour
|
||||
entry_name_colour
|
||||
entry_hours_colour
|
||||
|
||||
header_text_colour
|
||||
[subheader_name_colour, subheader_value_colour]
|
||||
|
||||
entry_bg_colour
|
||||
entry_bg_highlight_colour
|
||||
"""
|
||||
|
||||
leaderboard_page = Page(
|
||||
display_name=_p('skinsettings|page:leaderboard|display_name', "Leaderboard"),
|
||||
editing_description=_p(
|
||||
'skinsettings|page:leaderboard|edit_desc',
|
||||
"Options for the Leaderboard pages."
|
||||
),
|
||||
preview_description=None,
|
||||
visible_in_preview=True,
|
||||
render_card=LeaderboardCard
|
||||
)
|
||||
|
||||
header_text_colour = ColourSetting(
|
||||
card=LeaderboardCard,
|
||||
property_name='header_text_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:leaderboard|set:header_text_colour|display_name',
|
||||
"Header"
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:leaderboard|set:header_text_colour|desc',
|
||||
"Text colour of the leaderboard header."
|
||||
)
|
||||
)
|
||||
|
||||
subheader_name_colour = ColourSetting(
|
||||
card=LeaderboardCard,
|
||||
property_name='subheader_name_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:leaderboard|set:subheader_name_colour|display_name',
|
||||
""
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:leaderboard|set:subheader_name_colour|desc',
|
||||
""
|
||||
)
|
||||
)
|
||||
|
||||
subheader_value_colour = ColourSetting(
|
||||
card=LeaderboardCard,
|
||||
property_name='subheader_value_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:leaderboard|set:subheader_value_colour|display_name',
|
||||
""
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:leaderboard|set:subheader_value_colour|desc',
|
||||
""
|
||||
)
|
||||
)
|
||||
|
||||
subheader_colour = ColoursSetting(
|
||||
subheader_value_colour,
|
||||
subheader_name_colour,
|
||||
display_name=_p(
|
||||
'skinsettings|page:leaderboard|set:subheader_colour|display_name',
|
||||
"Sub-Header"
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:leaderboard|set:subheader_colour|desc',
|
||||
"Text colour of the sub-header line."
|
||||
)
|
||||
)
|
||||
|
||||
top_position_colour = ColourSetting(
|
||||
card=LeaderboardCard,
|
||||
property_name='top_position_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:leaderboard|set:top_position_colour|display_name',
|
||||
"Position"
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:leaderboard|set:top_position_colour|desc',
|
||||
"Top 3 position colour."
|
||||
)
|
||||
)
|
||||
|
||||
top_name_colour = ColourSetting(
|
||||
card=LeaderboardCard,
|
||||
property_name='top_name_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:leaderboard|set:top_name_colour|display_name',
|
||||
"Name"
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:leaderboard|set:top_name_colour|desc',
|
||||
"Top 3 name colour."
|
||||
)
|
||||
)
|
||||
|
||||
top_hours_colour = ColourSetting(
|
||||
card=LeaderboardCard,
|
||||
property_name='top_hours_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:leaderboard|set:top_hours_colour|display_name',
|
||||
"Hours"
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:leaderboard|set:top_hours_colour|desc',
|
||||
"Top 3 hours colour."
|
||||
)
|
||||
)
|
||||
|
||||
entry_position_colour = ColourSetting(
|
||||
card=LeaderboardCard,
|
||||
property_name='entry_position_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:leaderboard|set:entry_position_colour|display_name',
|
||||
"Position"
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:leaderboard|set:entry_position_colour|desc',
|
||||
"Position text colour."
|
||||
)
|
||||
)
|
||||
|
||||
entry_position_highlight_colour = ColourSetting(
|
||||
card=LeaderboardCard,
|
||||
property_name='entry_position_highlight_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:leaderboard|set:entry_position_highlight_colour|display_name',
|
||||
"Position (HL)"
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:leaderboard|set:entry_position_highlight_colour|desc',
|
||||
"Highlighted position colour."
|
||||
)
|
||||
)
|
||||
|
||||
entry_name_colour = ColourSetting(
|
||||
card=LeaderboardCard,
|
||||
property_name='entry_name_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:leaderboard|set:entry_name_colour|display_name',
|
||||
"Name"
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:leaderboard|set:entry_name_colour|desc',
|
||||
"Entry name colour."
|
||||
)
|
||||
)
|
||||
|
||||
entry_hours_colour = ColourSetting(
|
||||
card=LeaderboardCard,
|
||||
property_name='entry_hours_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:leaderboard|set:entry_hours_colour|display_name',
|
||||
"Hours"
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:leaderboard|set:entry_hours_colour|desc',
|
||||
"Entry hours colour."
|
||||
)
|
||||
)
|
||||
|
||||
entry_bg_colour = ColourSetting(
|
||||
card=LeaderboardCard,
|
||||
property_name='entry_bg_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:leaderboard|set:entry_bg_colour|display_name',
|
||||
"Regular"
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:leaderboard|set:entry_bg_colour|desc',
|
||||
"Background colour of regular entries."
|
||||
)
|
||||
)
|
||||
|
||||
entry_bg_highlight_colour = ColourSetting(
|
||||
card=LeaderboardCard,
|
||||
property_name='entry_bg_highlight_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:leaderboard|set:entry_bg_highlight_colour|display_name',
|
||||
"Highlighted"
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:leaderboard|set:entry_bg_highlight_colour|desc',
|
||||
"Background colour of highlighted entries."
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
top_colour_group = SettingGroup(
|
||||
_p('skinsettings|page:leaderboard|grp:top_colour', "Top 3"),
|
||||
description=_p(
|
||||
'skinsettings|page:leaderboard|grp:top_colour|desc',
|
||||
"Customise the text colours for the top 3 positions."
|
||||
),
|
||||
custom_id='leaderboard-top',
|
||||
settings=(
|
||||
top_position_colour,
|
||||
top_name_colour,
|
||||
top_hours_colour
|
||||
)
|
||||
)
|
||||
|
||||
entry_text_group = SettingGroup(
|
||||
_p('skinsettings|page:leaderboard|grp:entry_text', "Entry Text"),
|
||||
description=_p(
|
||||
'skinsettings|page:leaderboard|grp:entry_text|desc',
|
||||
"Text colours of the leaderboard entries."
|
||||
),
|
||||
custom_id='leaderboard-text',
|
||||
settings=(
|
||||
entry_position_colour,
|
||||
entry_position_highlight_colour,
|
||||
entry_name_colour,
|
||||
entry_hours_colour
|
||||
)
|
||||
)
|
||||
|
||||
entry_bg_group = SettingGroup(
|
||||
_p('skinsettings|page:leaderboard|grp:entry_bg', "Entry Background"),
|
||||
description=_p(
|
||||
'skinsettings|page:leaderboard|grp:entry_bg|desc',
|
||||
"Background colours of the leaderboard entries."
|
||||
),
|
||||
custom_id='leaderboard-bg',
|
||||
settings=(
|
||||
entry_bg_colour,
|
||||
entry_bg_highlight_colour
|
||||
)
|
||||
)
|
||||
|
||||
misc_group = SettingGroup(
|
||||
_p('skinsettings|page:leaderboard|grp:misc', "Miscellaneous"),
|
||||
description=_p(
|
||||
'skinsettings|page:leaderboard|grp:misc|desc',
|
||||
"Other miscellaneous colour settings."
|
||||
),
|
||||
custom_id='leaderboard-misc',
|
||||
settings=(
|
||||
header_text_colour,
|
||||
subheader_colour
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
base_skin = SkinSetting(
|
||||
card=LeaderboardCard,
|
||||
property_name='base_skin_id',
|
||||
display_name=_p(
|
||||
'skinsettings|page:leaderboard|set:base_skin|display_name',
|
||||
'Skin'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:leaderboard|set:base_skin|desc',
|
||||
"Select a Skin Preset."
|
||||
)
|
||||
)
|
||||
|
||||
base_skin_group = SettingGroup(
|
||||
_p('skinsettings|page:leaderboard|grp:base_skin', "Leaderboard Skin"),
|
||||
description=_p(
|
||||
'skinsettings|page:leaderboard|grp:base_skin|desc',
|
||||
"Asset pack and default values for the Leaderboard."
|
||||
),
|
||||
custom_id='leaderboard-skin',
|
||||
settings=(base_skin,),
|
||||
ungrouped=True
|
||||
)
|
||||
|
||||
leaderboard_page.groups = [
|
||||
base_skin_group,
|
||||
top_colour_group,
|
||||
entry_text_group,
|
||||
entry_bg_group,
|
||||
misc_group
|
||||
]
|
||||
|
||||
376
src/modules/skins/editor/pages/monthly.py
Normal file
376
src/modules/skins/editor/pages/monthly.py
Normal file
@@ -0,0 +1,376 @@
|
||||
from gui.cards import MonthlyStatsCard
|
||||
|
||||
from ... import babel
|
||||
from ..skinsetting import ColourSetting, SkinSetting, ColoursSetting
|
||||
from ..layout import Page, SettingGroup
|
||||
|
||||
_p = babel._p
|
||||
|
||||
""""
|
||||
title_colour
|
||||
[this_month_colour, last_month_colour]
|
||||
[stats_key_colour, stats_value_colour]
|
||||
footer_colour
|
||||
|
||||
top_hours_colour
|
||||
top_hours_bg_colour
|
||||
top_date_colour
|
||||
top_line_colour
|
||||
|
||||
top_this_colour
|
||||
top_this_hours_colour
|
||||
top_last_colour
|
||||
top_last_hours_colour
|
||||
|
||||
weekday_background_colour
|
||||
weekday_colour
|
||||
month_background_colour
|
||||
month_colour
|
||||
"""
|
||||
|
||||
monthly_page = Page(
|
||||
display_name=_p('skinsettings|page:monthly|display_name', "Monthly Statistics"),
|
||||
editing_description=_p(
|
||||
'skinsettings|page:monthly|edit_desc',
|
||||
"Options for the monthly statistis card."
|
||||
),
|
||||
preview_description=None,
|
||||
visible_in_preview=True,
|
||||
render_card=MonthlyStatsCard
|
||||
)
|
||||
|
||||
|
||||
title_colour = ColourSetting(
|
||||
card=MonthlyStatsCard,
|
||||
property_name='title_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly|set:title_colour|display_name',
|
||||
'Title'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly|set:title_colour|desc',
|
||||
"Colour of the card title."
|
||||
)
|
||||
)
|
||||
top_hours_colour = ColourSetting(
|
||||
card=MonthlyStatsCard,
|
||||
property_name='top_hours_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly|set:top_hours_colour|display_name',
|
||||
'Hours'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly|set:top_hours_colour|desc',
|
||||
"Hour axis labels."
|
||||
)
|
||||
)
|
||||
top_hours_bg_colour = ColourSetting(
|
||||
card=MonthlyStatsCard,
|
||||
property_name='top_hours_bg_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly|set:top_hours_bg_colour|display_name',
|
||||
'Hours Bg'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly|set:top_hours_bg_colour|desc',
|
||||
"Hour axis label background."
|
||||
)
|
||||
)
|
||||
top_line_colour = ColourSetting(
|
||||
card=MonthlyStatsCard,
|
||||
property_name='top_line_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly|set:top_line_colour|display_name',
|
||||
'Lines'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly|set:top_line_colour|desc',
|
||||
"Horizontal graph lines."
|
||||
)
|
||||
)
|
||||
top_date_colour = ColourSetting(
|
||||
card=MonthlyStatsCard,
|
||||
property_name='top_date_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly|set:top_date_colour|display_name',
|
||||
'Days'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly|set:top_date_colour|desc',
|
||||
"Day axis labels."
|
||||
)
|
||||
)
|
||||
top_this_colour = ColourSetting(
|
||||
card=MonthlyStatsCard,
|
||||
property_name='top_this_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly|set:top_this_colour|display_name',
|
||||
'This Month Bar'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly|set:top_this_colour|desc',
|
||||
"This month bar fill colour."
|
||||
)
|
||||
)
|
||||
top_last_colour = ColourSetting(
|
||||
card=MonthlyStatsCard,
|
||||
property_name='top_last_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly|set:top_last_colour|display_name',
|
||||
'Last Month Bar'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly|set:top_last_colour|desc',
|
||||
"Last month bar fill colour."
|
||||
)
|
||||
)
|
||||
top_this_hours_colour = ColourSetting(
|
||||
card=MonthlyStatsCard,
|
||||
property_name='top_this_hours_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly|set:top_this_hours_colour|display_name',
|
||||
'This Month Hours'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly|set:top_this_hours_colour|desc',
|
||||
"This month hour text."
|
||||
)
|
||||
)
|
||||
top_last_hours_colour = ColourSetting(
|
||||
card=MonthlyStatsCard,
|
||||
property_name='top_last_hours_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly|set:top_last_hours_colour|display_name',
|
||||
'Last Month Hours'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly|set:top_last_hours_colour|desc',
|
||||
"Last month hour text."
|
||||
)
|
||||
)
|
||||
this_month_colour = ColourSetting(
|
||||
card=MonthlyStatsCard,
|
||||
property_name='this_month_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly|set:this_month_colour|display_name',
|
||||
'This Month Legend'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly|set:this_month_colour|desc',
|
||||
"This month legend text."
|
||||
)
|
||||
)
|
||||
last_month_colour = ColourSetting(
|
||||
card=MonthlyStatsCard,
|
||||
property_name='last_month_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly|set:last_month_colour|display_name',
|
||||
'Last Month Legend'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly|set:last_month_colour|desc',
|
||||
"Last month legend text."
|
||||
)
|
||||
)
|
||||
legend_colour = ColoursSetting(
|
||||
this_month_colour,
|
||||
last_month_colour,
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly|set:legend_colour|display_name',
|
||||
'Legend'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly|set:legend_colour|desc',
|
||||
"Legend text colour."
|
||||
)
|
||||
)
|
||||
|
||||
weekday_background_colour = ColourSetting(
|
||||
card=MonthlyStatsCard,
|
||||
property_name='weekday_background_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly|set:weekday_background_colour|display_name',
|
||||
'Weekday Bg'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly|set:weekday_background_colour|desc',
|
||||
"Weekday axis background colour."
|
||||
)
|
||||
)
|
||||
weekday_colour = ColourSetting(
|
||||
card=MonthlyStatsCard,
|
||||
property_name='weekday_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly|set:weekday_colour|display_name',
|
||||
'Weekdays'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly|set:weekday_colour|desc',
|
||||
"Weekday axis labels."
|
||||
)
|
||||
)
|
||||
month_background_colour = ColourSetting(
|
||||
card=MonthlyStatsCard,
|
||||
property_name='month_background_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly|set:month_background_colour|display_name',
|
||||
'Month Bg'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly|set:month_background_colour|desc',
|
||||
"Month axis label backgrounds."
|
||||
)
|
||||
)
|
||||
month_colour = ColourSetting(
|
||||
card=MonthlyStatsCard,
|
||||
property_name='month_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly|set:month_colour|display_name',
|
||||
'Months'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly|set:month_colour|desc',
|
||||
"Month axis label text."
|
||||
)
|
||||
)
|
||||
stats_key_colour = ColourSetting(
|
||||
card=MonthlyStatsCard,
|
||||
property_name='stats_key_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly|set:stats_key_colour|display_name',
|
||||
'Stat Names'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly|set:stats_key_colour|desc',
|
||||
""
|
||||
)
|
||||
)
|
||||
stats_value_colour = ColourSetting(
|
||||
card=MonthlyStatsCard,
|
||||
property_name='stats_value_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly|set:stats_value_colour|display_name',
|
||||
'Stat Values'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly|set:stats_value_colour|desc',
|
||||
""
|
||||
)
|
||||
)
|
||||
statistics_colour = ColoursSetting(
|
||||
stats_key_colour,
|
||||
stats_value_colour,
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly|set:statistics_colour|display_name',
|
||||
'Statistics'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly|set:statistics_colour|desc',
|
||||
"Summary Statistics"
|
||||
)
|
||||
)
|
||||
footer_colour = ColourSetting(
|
||||
card=MonthlyStatsCard,
|
||||
property_name='footer_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly|set:footer_colour|display_name',
|
||||
'Footer'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly|set:footer_colour|desc',
|
||||
"Footer text colour."
|
||||
)
|
||||
)
|
||||
|
||||
top_graph_group = SettingGroup(
|
||||
_p('skinsettings|page:monthly|grp:top_graph', "Top Graph"),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly|grp:top_graph|desc',
|
||||
"Customise the axis and style of the top graph."
|
||||
),
|
||||
custom_id='monthly-top',
|
||||
settings=(
|
||||
top_hours_colour,
|
||||
top_hours_bg_colour,
|
||||
top_date_colour,
|
||||
top_line_colour
|
||||
)
|
||||
)
|
||||
|
||||
top_hours_group = SettingGroup(
|
||||
_p('skinsettings|page:monthly|grp:top_hours', "Hours"),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly|grp:top_hours|desc',
|
||||
"Customise the colour of this week and last week."
|
||||
),
|
||||
custom_id='monthly-hours',
|
||||
settings=(
|
||||
top_this_colour,
|
||||
top_this_hours_colour,
|
||||
top_last_colour,
|
||||
top_last_hours_colour
|
||||
)
|
||||
)
|
||||
|
||||
bottom_graph_group = SettingGroup(
|
||||
_p('skinsettings|page:monthly|grp:bottom_graph', "Heatmap"),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly|grp:bottom_graph|desc',
|
||||
"Customise the axis and style of the heatmap."
|
||||
),
|
||||
custom_id='monthly-heatmap',
|
||||
settings=(
|
||||
weekday_background_colour,
|
||||
weekday_colour,
|
||||
month_background_colour,
|
||||
month_colour
|
||||
)
|
||||
)
|
||||
|
||||
misc_group = SettingGroup(
|
||||
_p('skinsettings|page:monthly|grp:misc', "Miscellaneous"),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly|grp:misc|desc',
|
||||
"Miscellaneous colour options."
|
||||
),
|
||||
custom_id='monthly-misc',
|
||||
settings=(
|
||||
title_colour,
|
||||
legend_colour,
|
||||
statistics_colour,
|
||||
footer_colour
|
||||
)
|
||||
)
|
||||
|
||||
base_skin = SkinSetting(
|
||||
card=MonthlyStatsCard,
|
||||
property_name='base_skin_id',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly|set:base_skin|display_name',
|
||||
'Skin'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly|set:base_skin|desc',
|
||||
"Select a Skin Preset."
|
||||
)
|
||||
)
|
||||
|
||||
base_skin_group = SettingGroup(
|
||||
_p('skinsettings|page:monthly|grp:base_skin', "Monthly Stats Skin"),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly|grp:base_skin|desc',
|
||||
"Asset pack and default values for the Monthly Statistics."
|
||||
),
|
||||
custom_id='monthly-skin',
|
||||
settings=(base_skin,),
|
||||
ungrouped=True
|
||||
)
|
||||
|
||||
monthly_page.groups = [
|
||||
base_skin_group,
|
||||
top_graph_group,
|
||||
top_hours_group,
|
||||
bottom_graph_group,
|
||||
misc_group
|
||||
]
|
||||
|
||||
436
src/modules/skins/editor/pages/monthly_goals.py
Normal file
436
src/modules/skins/editor/pages/monthly_goals.py
Normal file
@@ -0,0 +1,436 @@
|
||||
from gui.cards import MonthlyGoalCard
|
||||
|
||||
from ... import babel
|
||||
from ..skinsetting import ColourSetting, SkinSetting, ColoursSetting
|
||||
from ..layout import Page, SettingGroup
|
||||
|
||||
_p = babel._p
|
||||
|
||||
"""
|
||||
mini_profile_name_colour
|
||||
mini_profile_badge_colour
|
||||
mini_profile_badge_text_colour
|
||||
mini_profile_discrim_colour
|
||||
|
||||
title_colour
|
||||
footer_colour
|
||||
|
||||
progress_bg_colour
|
||||
progress_colour
|
||||
[attendance_rate_colour, task_count_colour, studied_hour_colour]
|
||||
[attendance_colour, task_done_colour, studied_text_colour, task_goal_colour]
|
||||
task_goal_number_colour
|
||||
|
||||
task_header_colour
|
||||
task_done_number_colour
|
||||
task_undone_number_colour
|
||||
task_done_text_colour
|
||||
task_undone_text_colour
|
||||
"""
|
||||
|
||||
monthly_goal_page = Page(
|
||||
display_name=_p('skinsettings|page:monthly_goal|display_name', "Monthly Goals"),
|
||||
editing_description=_p(
|
||||
'skinsettings|page:monthly_goal|edit_desc',
|
||||
"Options for the monthly goal card."
|
||||
),
|
||||
preview_description=None,
|
||||
visible_in_preview=True,
|
||||
render_card=MonthlyGoalCard
|
||||
)
|
||||
|
||||
title_colour = ColourSetting(
|
||||
card=MonthlyGoalCard,
|
||||
property_name='title_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly_goal|set:title_colour|display_name',
|
||||
"Title"
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly_goal|set:title_colour|desc',
|
||||
""
|
||||
)
|
||||
)
|
||||
|
||||
progress_bg_colour = ColourSetting(
|
||||
card=MonthlyGoalCard,
|
||||
property_name='progress_bg_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly_goal|set:progress_bg_colour|display_name',
|
||||
"Bar Bg"
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly_goal|set:progress_bg_colour|desc',
|
||||
""
|
||||
)
|
||||
)
|
||||
|
||||
progress_colour = ColourSetting(
|
||||
card=MonthlyGoalCard,
|
||||
property_name='progress_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly_goal|set:progress_colour|display_name',
|
||||
"Bar Fg"
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly_goal|set:progress_colour|desc',
|
||||
""
|
||||
)
|
||||
)
|
||||
|
||||
attendance_rate_colour = ColourSetting(
|
||||
card=MonthlyGoalCard,
|
||||
property_name='attendance_rate_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly_goal|set:attendance_rate_colour|display_name',
|
||||
""
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly_goal|set:attendance_rate_colour|desc',
|
||||
""
|
||||
)
|
||||
)
|
||||
|
||||
attendance_colour = ColourSetting(
|
||||
card=MonthlyGoalCard,
|
||||
property_name='attendance_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly_goal|set:attendance_colour|display_name',
|
||||
""
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly_goal|set:attendance_colour|desc',
|
||||
""
|
||||
)
|
||||
)
|
||||
|
||||
task_count_colour = ColourSetting(
|
||||
card=MonthlyGoalCard,
|
||||
property_name='task_count_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly_goal|set:task_count_colour|display_name',
|
||||
""
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly_goal|set:task_count_colour|desc',
|
||||
""
|
||||
)
|
||||
)
|
||||
|
||||
task_done_colour = ColourSetting(
|
||||
card=MonthlyGoalCard,
|
||||
property_name='task_done_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly_goal|set:task_done_colour|display_name',
|
||||
""
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly_goal|set:task_done_colour|desc',
|
||||
""
|
||||
)
|
||||
)
|
||||
|
||||
task_goal_colour = ColourSetting(
|
||||
card=MonthlyGoalCard,
|
||||
property_name='task_goal_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly_goal|set:task_goal_colour|display_name',
|
||||
""
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly_goal|set:task_goal_colour|desc',
|
||||
""
|
||||
)
|
||||
)
|
||||
|
||||
task_goal_number_colour = ColourSetting(
|
||||
card=MonthlyGoalCard,
|
||||
property_name='task_goal_number_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly_goal|set:task_goal_number_colour|display_name',
|
||||
"Task Goal"
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly_goal|set:task_goal_number_colour|desc',
|
||||
""
|
||||
)
|
||||
)
|
||||
|
||||
studied_text_colour = ColourSetting(
|
||||
card=MonthlyGoalCard,
|
||||
property_name='studied_text_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly_goal|set:studied_text_colour|display_name',
|
||||
""
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly_goal|set:studied_text_colour|desc',
|
||||
""
|
||||
)
|
||||
)
|
||||
|
||||
studied_hour_colour = ColourSetting(
|
||||
card=MonthlyGoalCard,
|
||||
property_name='studied_hour_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly_goal|set:studied_hour_colour|display_name',
|
||||
""
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly_goal|set:studied_hour_colour|desc',
|
||||
""
|
||||
)
|
||||
)
|
||||
|
||||
text_highlight_colour = ColoursSetting(
|
||||
attendance_rate_colour,
|
||||
task_count_colour,
|
||||
studied_hour_colour,
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly_goal|set:text_highlight_colour|display_name',
|
||||
"Highlight Text"
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly_goal|set:text_highlight_colour|desc',
|
||||
"Progress text colour."
|
||||
)
|
||||
)
|
||||
|
||||
text_colour = ColoursSetting(
|
||||
attendance_colour,
|
||||
task_done_colour,
|
||||
studied_text_colour,
|
||||
task_goal_colour,
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly_goal|set:text_colour|display_name',
|
||||
"Text"
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly_goal|set:text_colour|desc',
|
||||
"Achievement description text colour."
|
||||
)
|
||||
)
|
||||
|
||||
task_header_colour = ColourSetting(
|
||||
card=MonthlyGoalCard,
|
||||
property_name='task_header_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly_goal|set:task_header_colour|display_name',
|
||||
"Task Header"
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly_goal|set:task_header_colour|desc',
|
||||
""
|
||||
)
|
||||
)
|
||||
|
||||
task_done_number_colour = ColourSetting(
|
||||
card=MonthlyGoalCard,
|
||||
property_name='task_done_number_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly_goal|set:task_done_number_colour|display_name',
|
||||
"Checked Number"
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly_goal|set:task_done_number_colour|desc',
|
||||
""
|
||||
)
|
||||
)
|
||||
|
||||
task_undone_number_colour = ColourSetting(
|
||||
card=MonthlyGoalCard,
|
||||
property_name='task_undone_number_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly_goal|set:task_undone_number_colour|display_name',
|
||||
"Unchecked Number"
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly_goal|set:task_undone_number_colour|desc',
|
||||
""
|
||||
)
|
||||
)
|
||||
|
||||
task_done_text_colour = ColourSetting(
|
||||
card=MonthlyGoalCard,
|
||||
property_name='task_done_text_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly_goal|set:task_done_text_colour|display_name',
|
||||
"Checked Text"
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly_goal|set:task_done_text_colour|desc',
|
||||
""
|
||||
)
|
||||
)
|
||||
|
||||
task_undone_text_colour = ColourSetting(
|
||||
card=MonthlyGoalCard,
|
||||
property_name='task_undone_text_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly_goal|set:task_undone_text_colour|display_name',
|
||||
"Unchecked Text"
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly_goal|set:task_undone_text_colour|desc',
|
||||
""
|
||||
)
|
||||
)
|
||||
|
||||
footer_colour = ColourSetting(
|
||||
card=MonthlyGoalCard,
|
||||
property_name='footer_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly_goal|set:footer_colour|display_name',
|
||||
"Footer"
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly_goal|set:footer_colour|desc',
|
||||
""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
mini_profile_badge_colour = ColourSetting(
|
||||
card=MonthlyGoalCard,
|
||||
property_name='mini_profile_badge_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly_goal|set:mini_profile_badge_colour|display_name',
|
||||
'Badge Background'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly_goal|set:mini_profile_badge_colour|desc',
|
||||
"Mini-profile badge background colour."
|
||||
)
|
||||
)
|
||||
|
||||
mini_profile_badge_text_colour = ColourSetting(
|
||||
card=MonthlyGoalCard,
|
||||
property_name='mini_profile_badge_text_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly_goal|set:mini_profile_badge_text_colour|display_name',
|
||||
'Badge Text'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly_goal|set:mini_profile_badge_text_colour|desc',
|
||||
""
|
||||
)
|
||||
)
|
||||
|
||||
mini_profile_name_colour = ColourSetting(
|
||||
card=MonthlyGoalCard,
|
||||
property_name='mini_profile_name_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly_goal|set:mini_profile_name_colour|display_name',
|
||||
'Username'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly_goal|set:mini_profile_name_colour|desc',
|
||||
"Mini-profile username colour."
|
||||
)
|
||||
)
|
||||
|
||||
mini_profile_discrim_colour = ColourSetting(
|
||||
card=MonthlyGoalCard,
|
||||
property_name='mini_profile_discrim_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly_goal|set:mini_profile_discrim_colour|display_name',
|
||||
'Discriminator'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly_goal|set:mini_profile_discrim_colour|desc',
|
||||
"Mini-profile discriminator colour."
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
mini_profile_group = SettingGroup(
|
||||
_p('skinsettings|page:monthly_goal|grp:mini_profile', "Profile"),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly_goal|grp:mini_profile|desc',
|
||||
"Customise the mini-profile shown above the goals."
|
||||
),
|
||||
custom_id='monthlygoal-profile',
|
||||
settings=(
|
||||
mini_profile_name_colour,
|
||||
mini_profile_discrim_colour,
|
||||
mini_profile_badge_colour,
|
||||
mini_profile_badge_text_colour,
|
||||
)
|
||||
)
|
||||
|
||||
misc_group = SettingGroup(
|
||||
_p('skinsettings|page:monthly_goal|grp:misc', "Miscellaneous"),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly_goal|grp:misc|desc',
|
||||
"Other miscellaneous colours."
|
||||
),
|
||||
custom_id='monthlygoal-misc',
|
||||
settings=(
|
||||
title_colour,
|
||||
footer_colour,
|
||||
)
|
||||
)
|
||||
|
||||
task_colour_group = SettingGroup(
|
||||
_p('skinsettings|page:monthly_goal|grp:task_colour', "Task colours"),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly_goal|grp:task_colour|desc',
|
||||
"Text and number colours for (in)complete goals."
|
||||
),
|
||||
custom_id='monthlygoal-tasks',
|
||||
settings=(
|
||||
task_undone_number_colour,
|
||||
task_done_number_colour,
|
||||
task_undone_text_colour,
|
||||
task_done_text_colour,
|
||||
)
|
||||
)
|
||||
|
||||
progress_colour_group = SettingGroup(
|
||||
_p('skinsettings|page:monthly_goal|grp:progress_colour', "Progress Colours"),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly_goal|grp:progress_colour|desc',
|
||||
"Customise colours for the monthly achievement progress."
|
||||
),
|
||||
custom_id='monthlygoal-progress',
|
||||
settings=(
|
||||
progress_bg_colour,
|
||||
progress_colour,
|
||||
text_colour,
|
||||
text_highlight_colour,
|
||||
task_goal_number_colour
|
||||
)
|
||||
)
|
||||
|
||||
base_skin = SkinSetting(
|
||||
card=MonthlyGoalCard,
|
||||
property_name='base_skin_id',
|
||||
display_name=_p(
|
||||
'skinsettings|page:monthly_goal|set:base_skin|display_name',
|
||||
'Skin'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly_goal|set:base_skin|desc',
|
||||
"Select a Skin Preset."
|
||||
)
|
||||
)
|
||||
|
||||
base_skin_group = SettingGroup(
|
||||
_p('skinsettings|page:monthly_goal|grp:base_skin', "Monthly Goals Skin"),
|
||||
description=_p(
|
||||
'skinsettings|page:monthly_goal|grp:base_skin|desc',
|
||||
"Asset pack and default values for the Monthly Goals."
|
||||
),
|
||||
custom_id='monthlygoals-skin',
|
||||
settings=(base_skin,),
|
||||
ungrouped=True
|
||||
)
|
||||
|
||||
monthly_goal_page.groups = [
|
||||
base_skin_group,
|
||||
mini_profile_group,
|
||||
misc_group,
|
||||
progress_colour_group,
|
||||
task_colour_group
|
||||
]
|
||||
|
||||
266
src/modules/skins/editor/pages/profile.py
Normal file
266
src/modules/skins/editor/pages/profile.py
Normal file
@@ -0,0 +1,266 @@
|
||||
from gui.cards import ProfileCard
|
||||
|
||||
from ... import babel
|
||||
from ..skinsetting import ColourSetting, SkinSetting, ColoursSetting
|
||||
from ..layout import Page, SettingGroup
|
||||
|
||||
_p = babel._p
|
||||
|
||||
|
||||
|
||||
profile_page = Page(
|
||||
display_name=_p('skinsettings|page:profile|display_name', "Member Profile"),
|
||||
editing_description=_p(
|
||||
'skinsettings|page:profile|edit_desc',
|
||||
"Options for the member profile card."
|
||||
),
|
||||
preview_description=None,
|
||||
visible_in_preview=True,
|
||||
render_card=ProfileCard
|
||||
)
|
||||
|
||||
|
||||
header_colour_1 = ColourSetting(
|
||||
card=ProfileCard,
|
||||
property_name='header_colour_1',
|
||||
display_name=_p(
|
||||
'skinsettings|page:profile|set:header_colour_1|display_name',
|
||||
'Username'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:profile|set:header_colour_1|desc',
|
||||
"Text colour of the profile username."
|
||||
)
|
||||
)
|
||||
|
||||
header_colour_2 = ColourSetting(
|
||||
card=ProfileCard,
|
||||
property_name='header_colour_2',
|
||||
display_name=_p(
|
||||
'skinsettings|page:profile|set:header_colour_2|display_name',
|
||||
'Discriminator'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:profile|set:header_colour_2|desc',
|
||||
"Text colour of the profile dscriminator."
|
||||
)
|
||||
)
|
||||
|
||||
counter_bg_colour = ColourSetting(
|
||||
card=ProfileCard,
|
||||
property_name='counter_bg_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:profile|set:counter_bg_colour|display_name',
|
||||
'Background'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:profile|set:counter_bg_colour|desc',
|
||||
"Colour of the coin/gem/gift backgrounds."
|
||||
)
|
||||
)
|
||||
|
||||
counter_colour = ColourSetting(
|
||||
card=ProfileCard,
|
||||
property_name='counter_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:profile|set:counter_colour|display_name',
|
||||
'Text'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:profile|set:counter_colour|desc',
|
||||
"Colour of the coin/gem/gift text."
|
||||
)
|
||||
)
|
||||
|
||||
subheader_colour = ColourSetting(
|
||||
card=ProfileCard,
|
||||
property_name='subheader_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:profile|set:subheader_colour|display_name',
|
||||
'Column Header'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:profile|set:subheader_colour|desc',
|
||||
"Colour of the Profile/Achievements header."
|
||||
)
|
||||
)
|
||||
|
||||
badge_text_colour = ColourSetting(
|
||||
card=ProfileCard,
|
||||
property_name='badge_text_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:profile|set:badge_text_colour|display_name',
|
||||
'Badge Text'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:profile|set:badge_text_colour|desc',
|
||||
"Colour of the profile badge text."
|
||||
)
|
||||
)
|
||||
|
||||
badge_blob_colour = ColourSetting(
|
||||
card=ProfileCard,
|
||||
property_name='badge_blob_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:profile|set:badge_blob_colour|display_name',
|
||||
'Background'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:profile|set:badge_blob_colour|desc',
|
||||
"Colour of the profile badge background."
|
||||
)
|
||||
)
|
||||
|
||||
rank_name_colour = ColourSetting(
|
||||
card=ProfileCard,
|
||||
property_name='rank_name_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:profile|set:rank_name_colour|display_name',
|
||||
'Current Rank'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:profile|set:rank_name_colour|desc',
|
||||
"Colour of the current study rank name."
|
||||
)
|
||||
)
|
||||
|
||||
rank_hours_colour = ColourSetting(
|
||||
card=ProfileCard,
|
||||
property_name='rank_hours_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:profile|set:rank_hours_colour|display_name',
|
||||
'Required Hours'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:profile|set:rank_hours_colour|desc',
|
||||
"Colour of the study rank hour range."
|
||||
)
|
||||
)
|
||||
|
||||
bar_full_colour = ColourSetting(
|
||||
card=ProfileCard,
|
||||
property_name='bar_full_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:profile|set:bar_full_colour|display_name',
|
||||
'Bar Full'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:profile|set:bar_full_colour|desc',
|
||||
"Foreground progress bar colour."
|
||||
)
|
||||
)
|
||||
|
||||
bar_empty_colour = ColourSetting(
|
||||
card=ProfileCard,
|
||||
property_name='bar_empty_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:profile|set:bar_empty_colour|display_name',
|
||||
'Bar Empty'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:profile|set:bar_empty_colour|desc',
|
||||
"Background progress bar colour."
|
||||
)
|
||||
)
|
||||
|
||||
next_rank_colour = ColourSetting(
|
||||
card=ProfileCard,
|
||||
property_name='next_rank_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:profile|set:next_rank_colour|display_name',
|
||||
'Next Rank'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:profile|set:next_rank_colour|desc',
|
||||
"Colour of the next rank name and hours."
|
||||
)
|
||||
)
|
||||
|
||||
title_colour_group = SettingGroup(
|
||||
_p('skinsettings|page:profile|grp:title_colour', "Title Colours"),
|
||||
description=_p(
|
||||
'skinsettings|page:profile|grp:title_colour|desc',
|
||||
"Header and suheader text colours."
|
||||
),
|
||||
custom_id='profile-titles',
|
||||
settings=(
|
||||
header_colour_1,
|
||||
header_colour_2,
|
||||
subheader_colour
|
||||
),
|
||||
)
|
||||
|
||||
badge_colour_group = SettingGroup(
|
||||
_p('skinsettings|page:profile|grp:badge_colour', "Profile Badge Colours"),
|
||||
description=_p(
|
||||
'skinsettings|page:profile|grp:badge_colour|desc',
|
||||
"Text and background for the profile badges."
|
||||
),
|
||||
custom_id='profile-badges',
|
||||
settings=(
|
||||
badge_text_colour,
|
||||
badge_blob_colour
|
||||
),
|
||||
)
|
||||
|
||||
counter_colour_group = SettingGroup(
|
||||
_p('skinsettings|page:profile|grp:counter_colour', "Counter Colours"),
|
||||
description=_p(
|
||||
'skinsettings|page:profile|grp:counter_colour|desc',
|
||||
"Text and background for the coin/gem/gift counters."
|
||||
),
|
||||
custom_id='profile-counters',
|
||||
settings=(
|
||||
counter_colour,
|
||||
counter_bg_colour
|
||||
),
|
||||
)
|
||||
|
||||
rank_colour_group = SettingGroup(
|
||||
_p('skinsettings|page:profile|grp:rank_colour', "Progress Bar"),
|
||||
description=_p(
|
||||
'skinsettings|page:profile|grp:rank_colour|desc',
|
||||
"Colours for the study badge/rank progress bar."
|
||||
),
|
||||
custom_id='profile-progress',
|
||||
settings=(
|
||||
rank_name_colour,
|
||||
rank_hours_colour,
|
||||
next_rank_colour,
|
||||
bar_full_colour,
|
||||
bar_empty_colour
|
||||
),
|
||||
)
|
||||
|
||||
base_skin = SkinSetting(
|
||||
card=ProfileCard,
|
||||
property_name='base_skin_id',
|
||||
display_name=_p(
|
||||
'skinsettings|page:profile|set:base_skin|display_name',
|
||||
'Skin'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:profile|set:base_skin|desc',
|
||||
"Select a Skin Preset."
|
||||
)
|
||||
)
|
||||
|
||||
base_skin_group = SettingGroup(
|
||||
_p('skinsettings|page:profile|grp:base_skin', "Profile Skin"),
|
||||
description=_p(
|
||||
'skinsettings|page:profile|grp:base_skin|desc',
|
||||
"Asset pack and default values for this card."
|
||||
),
|
||||
custom_id='profile-skin',
|
||||
settings=(base_skin,),
|
||||
ungrouped=True
|
||||
)
|
||||
|
||||
profile_page.groups = [
|
||||
base_skin_group,
|
||||
title_colour_group,
|
||||
badge_colour_group,
|
||||
rank_colour_group,
|
||||
counter_colour_group,
|
||||
]
|
||||
|
||||
211
src/modules/skins/editor/pages/stats.py
Normal file
211
src/modules/skins/editor/pages/stats.py
Normal file
@@ -0,0 +1,211 @@
|
||||
from gui.cards import StatsCard
|
||||
|
||||
from ... import babel
|
||||
from ..skinsetting import ColourSetting, SkinSetting, ColoursSetting
|
||||
from ..layout import Page, SettingGroup
|
||||
|
||||
_p = babel._p
|
||||
|
||||
|
||||
stats_page = Page(
|
||||
display_name=_p('skinsettings|page:stats|display_name', "Statistics"),
|
||||
editing_description=_p(
|
||||
'skinsettings|page:stats|edit_desc',
|
||||
"Options for the member statistics card."
|
||||
),
|
||||
preview_description=None,
|
||||
visible_in_preview=True,
|
||||
render_card=StatsCard
|
||||
)
|
||||
|
||||
|
||||
header_colour = ColourSetting(
|
||||
card=StatsCard,
|
||||
property_name='header_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:stats|set:header_colour|display_name',
|
||||
'Titles'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:stats|set:header_colour|desc',
|
||||
"Top header text colour."
|
||||
)
|
||||
)
|
||||
|
||||
stats_subheader_colour = ColourSetting(
|
||||
card=StatsCard,
|
||||
property_name='stats_subheader_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:stats|set:stats_subheader_colour|display_name',
|
||||
'Sections'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:stats|set:stats_subheader_colour|desc',
|
||||
"Text colour of the Statistics section titles."
|
||||
)
|
||||
)
|
||||
|
||||
stats_text_colour = ColourSetting(
|
||||
card=StatsCard,
|
||||
property_name='stats_text_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:stats|set:stats_text_colour|display_name',
|
||||
'Statistics'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:stats|set:stats_text_colour|desc',
|
||||
"Text colour of the Statistics section bodies."
|
||||
)
|
||||
)
|
||||
|
||||
col2_date_colour = ColourSetting(
|
||||
card=StatsCard,
|
||||
property_name='col2_date_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:stats|set:col2_date_colour|display_name',
|
||||
'Date'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:stats|set:col2_date_colour|desc',
|
||||
"Colour of the current month and year."
|
||||
)
|
||||
)
|
||||
|
||||
col2_hours_colour = ColourSetting(
|
||||
card=StatsCard,
|
||||
property_name='col2_hours_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:stats|set:col2_hours_colour|display_name',
|
||||
'Hours'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:stats|set:col2_hours_colour|desc',
|
||||
"Colour of the monthly hour total."
|
||||
)
|
||||
)
|
||||
|
||||
text_colour_group = SettingGroup(
|
||||
_p('skinsettings|page:stats|grp:text_colour', "Text Colours"),
|
||||
description=_p(
|
||||
'skinsettings|page:stats|grp:text_colour|desc',
|
||||
"Header colours and statistics text colours."
|
||||
),
|
||||
custom_id='stats-text',
|
||||
settings=(
|
||||
header_colour,
|
||||
stats_subheader_colour,
|
||||
stats_text_colour,
|
||||
col2_date_colour,
|
||||
col2_hours_colour
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
cal_weekday_colour = ColourSetting(
|
||||
card=StatsCard,
|
||||
property_name='cal_weekday_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:stats|set:cal_weekday_colour|display_name',
|
||||
'Weekdays'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:stats|set:cal_weekday_colour|desc',
|
||||
"Colour of the week day letters."
|
||||
),
|
||||
)
|
||||
|
||||
cal_number_colour = ColourSetting(
|
||||
card=StatsCard,
|
||||
property_name='cal_number_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:stats|set:cal_number_colour|display_name',
|
||||
'Numbers'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:stats|set:cal_number_colour|desc',
|
||||
"General calender day colour."
|
||||
),
|
||||
)
|
||||
|
||||
cal_number_end_colour = ColourSetting(
|
||||
card=StatsCard,
|
||||
property_name='cal_number_end_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:stats|set:cal_number_end_colour|display_name',
|
||||
'Streak Ends'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:stats|set:cal_number_end_colour|desc',
|
||||
"Day colour where streaks start or end."
|
||||
),
|
||||
)
|
||||
|
||||
cal_streak_middle_colour = ColourSetting(
|
||||
card=StatsCard,
|
||||
property_name='cal_streak_middle_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:stats|set:cal_streak_middle_colour|display_name',
|
||||
'Streak BG'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:stats|set:cal_streak_middle_colour|desc',
|
||||
"Background colour on streak days."
|
||||
),
|
||||
)
|
||||
|
||||
cal_streak_end_colour = ColourSetting(
|
||||
card=StatsCard,
|
||||
property_name='cal_streak_end_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:stats|set:cal_streak_end_colour|display_name',
|
||||
'Streak End BG'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:stats|set:cal_streak_end_colour|desc',
|
||||
"Background colour where streaks start/end."
|
||||
),
|
||||
)
|
||||
|
||||
calender_colour_group = SettingGroup(
|
||||
_p('skinsettings|page:stats|grp:calender_colour', "Calender Colours"),
|
||||
description=_p(
|
||||
'skinsettings|page:stats|grp:calender_colour|desc',
|
||||
"Number and streak colours for the current calender."
|
||||
),
|
||||
custom_id='stats-cal',
|
||||
settings=(
|
||||
cal_weekday_colour,
|
||||
cal_number_colour,
|
||||
cal_number_end_colour,
|
||||
cal_streak_middle_colour,
|
||||
cal_streak_end_colour
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
base_skin = SkinSetting(
|
||||
card=StatsCard,
|
||||
property_name='base_skin_id',
|
||||
display_name=_p(
|
||||
'skinsettings|page:stats|set:base_skin|display_name',
|
||||
'Skin'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:stats|set:base_skin|desc',
|
||||
"Select a Skin Preset."
|
||||
)
|
||||
)
|
||||
|
||||
base_skin_group = SettingGroup(
|
||||
_p('skinsettings|page:stats|grp:base_skin', "Statistics Skin"),
|
||||
description=_p(
|
||||
'skinsettings|page:stats|grp:base_skin|desc',
|
||||
"Asset pack and default values for this card."
|
||||
),
|
||||
custom_id='stats-skin',
|
||||
settings=(base_skin,),
|
||||
ungrouped=True
|
||||
)
|
||||
|
||||
stats_page.groups = [base_skin_group, text_colour_group, calender_colour_group]
|
||||
|
||||
89
src/modules/skins/editor/pages/summary.py
Normal file
89
src/modules/skins/editor/pages/summary.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from gui.cards import ProfileCard
|
||||
|
||||
from ... import babel
|
||||
from ..skinsetting import ColourSetting, SkinSetting, ColoursSetting
|
||||
from ..layout import Page, SettingGroup
|
||||
|
||||
from . import stats, profile
|
||||
|
||||
_p = babel._p
|
||||
|
||||
|
||||
summary_page = Page(
|
||||
display_name=_p('skinsettings|page:summary|display_name', "Setting Summary"),
|
||||
editing_description=_p(
|
||||
'skinsettings|page:summary|edit_desc',
|
||||
"Simple setup for creating a unified interface theme."
|
||||
),
|
||||
preview_description=_p(
|
||||
'skinsettings|page:summary|preview_desc',
|
||||
"Summary of common settings across the entire interface."
|
||||
),
|
||||
visible_in_preview=True,
|
||||
render_card=ProfileCard
|
||||
)
|
||||
|
||||
name_colours = ColoursSetting(
|
||||
profile.header_colour_1,
|
||||
display_name=_p(
|
||||
'skinsettings|page:summary|set:name_colours|display_name',
|
||||
"username colour"
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:summary|set:name_colours|desc',
|
||||
"Author username colour."
|
||||
)
|
||||
)
|
||||
|
||||
discrim_colours = ColoursSetting(
|
||||
profile.header_colour_2,
|
||||
display_name=_p(
|
||||
'skinsettings|page:summary|set:discrim_colours|display_name',
|
||||
"discrim colour"
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:summary|set:discrim_colours|desc',
|
||||
"Author discriminator colour."
|
||||
)
|
||||
)
|
||||
|
||||
subheader_colour = ColoursSetting(
|
||||
stats.header_colour,
|
||||
profile.subheader_colour,
|
||||
display_name=_p(
|
||||
'skinsettings|page:summary|set:subheader_colour|display_name',
|
||||
"subheadings"
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:summary|set:subheader_colour|desc',
|
||||
"Colour of subheadings and column headings."
|
||||
)
|
||||
)
|
||||
|
||||
header_colour_group = SettingGroup(
|
||||
_p('skinsettings|page:summary|grp:header_colour', "Title Colours"),
|
||||
description=_p(
|
||||
'skinsettings|page:summary|grp:header_colour|desc',
|
||||
"Title and header text colours."
|
||||
),
|
||||
custom_id='shared-titles',
|
||||
settings=(
|
||||
subheader_colour,
|
||||
)
|
||||
)
|
||||
|
||||
profile_colour_group = SettingGroup(
|
||||
_p('skinsettings|page:summary|grp:profile_colour', "Profile Colours"),
|
||||
description=_p(
|
||||
'skinsettings|page:summary|grp:profile_colour|desc',
|
||||
"Profile elements shared across various cards."
|
||||
),
|
||||
custom_id='shared-profile',
|
||||
settings=(
|
||||
name_colours,
|
||||
discrim_colours
|
||||
)
|
||||
)
|
||||
|
||||
summary_page.groups = [header_colour_group, profile_colour_group]
|
||||
|
||||
350
src/modules/skins/editor/pages/weekly.py
Normal file
350
src/modules/skins/editor/pages/weekly.py
Normal file
@@ -0,0 +1,350 @@
|
||||
from gui.cards import WeeklyStatsCard
|
||||
|
||||
from ... import babel
|
||||
from ..skinsetting import ColourSetting, SkinSetting, ColoursSetting
|
||||
from ..layout import Page, SettingGroup
|
||||
|
||||
_p = babel._p
|
||||
|
||||
|
||||
weekly_page = Page(
|
||||
display_name=_p('skinsettings|page:weekly|display_name', "Weekly Statistics"),
|
||||
editing_description=_p(
|
||||
'skinsettings|page:weekly|edit_desc',
|
||||
"Options for the weekly statistis card."
|
||||
),
|
||||
preview_description=None,
|
||||
visible_in_preview=True,
|
||||
render_card=WeeklyStatsCard
|
||||
)
|
||||
|
||||
title_colour = ColourSetting(
|
||||
card=WeeklyStatsCard,
|
||||
property_name='title_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:weekly|set:title_colour|display_name',
|
||||
'Title'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:weekly|set:title_colour|desc',
|
||||
"Colour of the card title."
|
||||
)
|
||||
)
|
||||
top_hours_colour = ColourSetting(
|
||||
card=WeeklyStatsCard,
|
||||
property_name='top_hours_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:weekly|set:top_hours_colour|display_name',
|
||||
'Hours'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:weekly|set:top_hours_colour|desc',
|
||||
"Hours axis labels."
|
||||
)
|
||||
)
|
||||
top_hours_bg_colour = ColourSetting(
|
||||
card=WeeklyStatsCard,
|
||||
property_name='top_hours_bg_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:weekly|set:top_hours_bg_colour|display_name',
|
||||
'Hour Bg'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:weekly|set:top_hours_bg_colour|desc',
|
||||
"Hours axis label background."
|
||||
)
|
||||
)
|
||||
top_line_colour = ColourSetting(
|
||||
card=WeeklyStatsCard,
|
||||
property_name='top_line_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:weekly|set:top_line_colour|display_name',
|
||||
'Lines'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:weekly|set:top_line_colour|desc',
|
||||
"Horizontal graph lines."
|
||||
)
|
||||
)
|
||||
top_weekday_colour = ColourSetting(
|
||||
card=WeeklyStatsCard,
|
||||
property_name='top_weekday_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:weekly|set:top_weekday_colour|display_name',
|
||||
'Weekdays'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:weekly|set:top_weekday_colour|desc',
|
||||
"Weekday axis labels."
|
||||
)
|
||||
)
|
||||
top_date_colour = ColourSetting(
|
||||
card=WeeklyStatsCard,
|
||||
property_name='top_date_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:weekly|set:top_date_colour|display_name',
|
||||
'Dates'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:weekly|set:top_date_colour|desc',
|
||||
"Weekday date axis labels."
|
||||
)
|
||||
)
|
||||
top_this_colour = ColourSetting(
|
||||
card=WeeklyStatsCard,
|
||||
property_name='top_this_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:weekly|set:top_this_colour|display_name',
|
||||
'This Week'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:weekly|set:top_this_colour|desc',
|
||||
"This week bar fill colour."
|
||||
)
|
||||
)
|
||||
top_last_colour = ColourSetting(
|
||||
card=WeeklyStatsCard,
|
||||
property_name='top_last_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:weekly|set:top_last_colour|display_name',
|
||||
'Last Week'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:weekly|set:top_last_colour|desc',
|
||||
"Last week bar fill colour."
|
||||
)
|
||||
)
|
||||
btm_weekday_colour = ColourSetting(
|
||||
card=WeeklyStatsCard,
|
||||
property_name='btm_weekday_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:weekly|set:btm_weekday_colour|display_name',
|
||||
'Weekdays'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:weekly|set:btm_weekday_colour|desc',
|
||||
"Weekday axis labels."
|
||||
)
|
||||
)
|
||||
btm_weekly_background_colour = ColourSetting(
|
||||
card=WeeklyStatsCard,
|
||||
property_name='btm_weekly_background_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:weekly|set:btm_weekly_background_colour|display_name',
|
||||
'Weekday Bg'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:weekly|set:btm_weekly_background_colour|desc',
|
||||
"Weekday axis background."
|
||||
)
|
||||
)
|
||||
btm_bar_horiz_colour = ColourSetting(
|
||||
card=WeeklyStatsCard,
|
||||
property_name='btm_bar_horiz_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:weekly|set:btm_bar_horiz_colour|display_name',
|
||||
'Bars (Horiz)'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:weekly|set:btm_bar_horiz_colour|desc',
|
||||
"Horizontal graph bars."
|
||||
)
|
||||
)
|
||||
btm_bar_vert_colour = ColourSetting(
|
||||
card=WeeklyStatsCard,
|
||||
property_name='btm_bar_vert_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:weekly|set:btm_bar_vert_colour|display_name',
|
||||
'Bars (Vertical)'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:weekly|set:btm_bar_vert_colour|desc',
|
||||
"Vertical graph bars."
|
||||
)
|
||||
)
|
||||
btm_this_colour = ColourSetting(
|
||||
card=WeeklyStatsCard,
|
||||
property_name='btm_this_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:weekly|set:btm_this_colour|display_name',
|
||||
'This Week'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:weekly|set:btm_this_colour|desc',
|
||||
"This week bar fill colour."
|
||||
)
|
||||
)
|
||||
btm_last_colour = ColourSetting(
|
||||
card=WeeklyStatsCard,
|
||||
property_name='btm_last_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:weekly|set:btm_last_colour|display_name',
|
||||
'Last Week'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:weekly|set:btm_last_colour|desc',
|
||||
"Last week bar fill colour."
|
||||
)
|
||||
)
|
||||
btm_day_colour = ColourSetting(
|
||||
card=WeeklyStatsCard,
|
||||
property_name='btm_day_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:weekly|set:btm_day_colour|display_name',
|
||||
'Hours'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:weekly|set:btm_day_colour|desc',
|
||||
"Hour axis labels."
|
||||
)
|
||||
)
|
||||
this_week_colour = ColourSetting(
|
||||
card=WeeklyStatsCard,
|
||||
property_name='this_week_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:weekly|set:this_week_colour|display_name',
|
||||
'This Week Legend'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:weekly|set:this_week_colour|desc',
|
||||
"This week legend text."
|
||||
)
|
||||
)
|
||||
last_week_colour = ColourSetting(
|
||||
card=WeeklyStatsCard,
|
||||
property_name='last_week_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:weekly|set:last_week_colour|display_name',
|
||||
'Last Week Legend'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:weekly|set:last_week_colour|desc',
|
||||
"Last week legend text."
|
||||
)
|
||||
)
|
||||
legend_colour = ColoursSetting(
|
||||
this_week_colour,
|
||||
last_week_colour,
|
||||
display_name=_p(
|
||||
'skinsettings|page:weekly|set:legend_colour|display_name',
|
||||
'Legend'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:weekly|set:legend_colour|desc',
|
||||
"Legend text colour."
|
||||
)
|
||||
)
|
||||
footer_colour = ColourSetting(
|
||||
card=WeeklyStatsCard,
|
||||
property_name='footer_colour',
|
||||
display_name=_p(
|
||||
'skinsettings|page:weekly|set:footer_colour|display_name',
|
||||
'Footer'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:weekly|set:footer_colour|desc',
|
||||
"Footer text colour."
|
||||
)
|
||||
)
|
||||
|
||||
base_skin = SkinSetting(
|
||||
card=WeeklyStatsCard,
|
||||
property_name='base_skin_id',
|
||||
display_name=_p(
|
||||
'skinsettings|page:weekly|set:base_skin|display_name',
|
||||
'Skin'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:weekly|set:base_skin|desc',
|
||||
"Select a Skin Preset."
|
||||
)
|
||||
)
|
||||
|
||||
base_skin_group = SettingGroup(
|
||||
_p('skinsettings|page:weekly|grp:base_skin', "Weekly Stats Skin"),
|
||||
description=_p(
|
||||
'skinsettings|page:weekly|grp:base_skin|desc',
|
||||
"Asset pack and default values for this card."
|
||||
),
|
||||
custom_id='weekly-skin',
|
||||
settings=(base_skin,),
|
||||
ungrouped=True
|
||||
)
|
||||
|
||||
top_colour_group = SettingGroup(
|
||||
_p('skinsettings|page:weekly|grp:top_colour', "Top Graph"),
|
||||
description=_p(
|
||||
'skinsettings|page:weekly|grp:top_colour|desc',
|
||||
"Customise the top graph colourscheme."
|
||||
),
|
||||
custom_id='weekly-top',
|
||||
settings=(
|
||||
top_hours_colour,
|
||||
top_weekday_colour,
|
||||
top_date_colour,
|
||||
top_this_colour,
|
||||
top_last_colour,
|
||||
)
|
||||
)
|
||||
|
||||
bottom_colour_group = SettingGroup(
|
||||
_p('skinsettings|page:weekly|grp:bottom_colour', "Bottom Graph"),
|
||||
description=_p(
|
||||
'skinsettings|page:weekly|grp:bottom_colour|desc',
|
||||
"Customise the bottom graph colourscheme."
|
||||
),
|
||||
custom_id='weekly-bottom',
|
||||
settings=(
|
||||
btm_weekday_colour,
|
||||
btm_day_colour,
|
||||
btm_this_colour,
|
||||
btm_last_colour,
|
||||
btm_bar_horiz_colour,
|
||||
)
|
||||
)
|
||||
|
||||
misc_group = SettingGroup(
|
||||
_p('skinsettings|page:weekly|grp:misc', "Misc Colours"),
|
||||
description=_p(
|
||||
'skinsettings|page:weekly|grp:misc|desc',
|
||||
"Miscellaneous card colours."
|
||||
),
|
||||
custom_id='weekly-misc',
|
||||
settings=(
|
||||
title_colour,
|
||||
legend_colour,
|
||||
footer_colour,
|
||||
)
|
||||
)
|
||||
|
||||
base_skin = SkinSetting(
|
||||
card=WeeklyStatsCard,
|
||||
property_name='base_skin_id',
|
||||
display_name=_p(
|
||||
'skinsettings|page:weekly|set:base_skin|display_name',
|
||||
'Skin'
|
||||
),
|
||||
description=_p(
|
||||
'skinsettings|page:weekly|set:base_skin|desc',
|
||||
"Select a Skin Preset."
|
||||
)
|
||||
)
|
||||
|
||||
base_skin_group = SettingGroup(
|
||||
_p('skinsettings|page:weekly|grp:base_skin', "Weekly Stats Skin"),
|
||||
description=_p(
|
||||
'skinsettings|page:weekly|grp:base_skin|desc',
|
||||
"Asset pack and default values for the Weekly Statistics."
|
||||
),
|
||||
custom_id='weekly-skin',
|
||||
settings=(base_skin,),
|
||||
ungrouped=True
|
||||
)
|
||||
|
||||
weekly_page.groups = [
|
||||
base_skin_group,
|
||||
top_colour_group,
|
||||
bottom_colour_group,
|
||||
misc_group,
|
||||
]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user