Merge branch 'rewrite' into pillow
This commit is contained in:
53
LICENSE.md
53
LICENSE.md
@@ -1,10 +1,45 @@
|
||||
Copyright (c) 2022, Ari Horesh.
|
||||
All rights reserved.
|
||||
StudyLion Open Source License
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Copyright (c) 2023, Ari Horesh. All rights reserved.
|
||||
|
||||
### 1. Definitions
|
||||
|
||||
- "Software": Refers to the Discord bot named "StudyLion" and associated documentation, source code, scripts, assets, and other related materials.
|
||||
- "Educational Use": Means utilization of the Software primarily for learning, teaching, training, research, or development.
|
||||
- "Non-commercial Use": Describes an application of the Software where there's no expectation or realization of direct or indirect monetary compensation.
|
||||
|
||||
### 2. Grant of License
|
||||
|
||||
- Under the terms and conditions of this license, the licensor grants a worldwide, non-exclusive, royalty-free, non-transferable license to:
|
||||
- Use the Software.
|
||||
- Reproduce the Software.
|
||||
- Modify the Software, creating derivative works based on the Software.
|
||||
- Distribute the unmodified Software for Educational and Non-commercial Use.
|
||||
|
||||
### 3. Restrictions
|
||||
|
||||
- Redistribution, whether modified or unmodified, must:
|
||||
- Preserve the above copyright notice.
|
||||
- Incorporate this list of conditions.
|
||||
- Include the following disclaimers.
|
||||
|
||||
- You must not:
|
||||
- Use the name, trademarks, service marks, or names of the Software or its contributors to endorse or promote derivative products without prior written consent.
|
||||
- Deploy the Software or any derivative works thereof for commercial purposes or any context leading to financial gain.
|
||||
- Assert proprietary rights or assign authorship of the original Software to any entity other than the original authors.
|
||||
- Grant sublicenses for the Software.
|
||||
|
||||
### 4. Contributions
|
||||
|
||||
- Any contributions made to the Software by third parties shall be subject to this license. The contributor grants the licensor a non-exclusive, perpetual, irrevocable license to any such contributions.
|
||||
|
||||
### 5. Termination
|
||||
|
||||
- If you breach any terms of this license, your rights will terminate automatically. Once terminated, you must:
|
||||
- Halt all utilization of the Software.
|
||||
- Destroy or delete all copies of the Software in your possession or control.
|
||||
|
||||
### 6. Disclaimer and Limitation of Liability
|
||||
|
||||
- THE SOFTWARE IS OFFERED "AS IS", WITHOUT ANY GUARANTEES OR CLAIMS OF EFFICACY. NO WARRANTIES, IMPLIED OR EXPLICIT, ARE PROVIDED. THIS INCLUDES, BUT IS NOT RESTRICTED TO, WARRANTIES OF MERCHANDISE, FITNESS FOR A SPECIFIC PURPOSE, AND NON-INFRINGEMENT.
|
||||
- UNDER NO CONDITION SHALL THE AUTHORS, COPYRIGHT HOLDERS, OR CONTRIBUTORS BE ACCOUNTABLE FOR ANY CLAIMS, DAMAGES, OR OTHER LIABILITIES, WHETHER RESULTING FROM CONTRACT, TORT, NEGLIGENCE, OR ANY OTHER LEGAL THEORY, EMERGING FROM, OUT OF, OR CONNECTED WITH THE SOFTWARE OR ITS USE.
|
||||
|
||||
20
README.md
20
README.md
@@ -1,13 +1,13 @@
|
||||
|
||||
## StudyLion - Discord Study & Productivity Bot
|
||||
## LionBot (formerly StudyLion) - Discord Study & Productivity Bot
|
||||
|
||||
StudyLion is a Discord bot that tracks members' study and work time while offering members the ability to view their statistics and use productivity tools such as: To-do lists, pomodoro timers, reminders, and much more.
|
||||
LionBot is a Discord bot that tracks members' study and work time while offering members the ability to view their statistics and use productivity tools such as: To-do lists, pomodoro timers, reminders, and much more.
|
||||
|
||||
|
||||
|
||||
[**Invite StudyLion here**](https://discord.studylions.com/invite "here"), and get started with `!help`.
|
||||
[**Invite LionBot here**](https://discord.com/oauth2/authorize?client_id=889078613817831495&permissions=8&scope=bot), and get started with `/help`.
|
||||
|
||||
Join the [**support server**](https://discord.gg/studylions "support server") to contact us if you need help configuring the bot on your server, or start a [**discussion**](https://github.com/StudyLions/StudyLion/discussions "disscussion") to report issues and bugs.
|
||||
Join the [**support server**](https://discord.gg/the-study-lions-780195610154237993) to contact us if you need help configuring the bot on your server, or start a [**discussion**](https://github.com/LionBots/LionBot/discussions "disscussion") to report issues and bugs.
|
||||
|
||||
|
||||
|
||||
@@ -18,14 +18,11 @@ In the past couple of years, we noticed a new trend on Discord – instead of be
|
||||
|
||||
|
||||
|
||||
This bot was founder by [Ari Horesh](https://www.youtube.com/arihoresh) (Ari Horesh#0001) to support these forming study communities and allow students all over the world to study better.
|
||||
This bot was founder by [Ari Horesh](https://www.youtube.com/arihoresh) (@AriHoresh) to support these forming study communities and allow students all over the world to study better.
|
||||
|
||||
### Self Hosting
|
||||
|
||||
We offer private instances based on availablity (a private bot for your community) to server owners who want their own branding (logo, color scheme, private and seperate database, better response-rate, and customizability to the text itself).
|
||||
If you are intrested, contact the founder at contact@arihoresh.com .
|
||||
|
||||
You can self-host and fork the bot using the following steps, but beware that this version **does not include** our visual graphical user interface, which is only include in the custom private instances or our the public instance.
|
||||
You can self-host and fork the bot using the following steps, but beware that we do not provide support for self-hosted instances. If you are interested in a privately managed instance (affordable paid service), contact Ari at contact@arihoresh.com
|
||||
|
||||
Follow the steps below to self-host the bot.
|
||||
- Clone the repo recursively (which makes sure to include the cmdClient submodule, otherwise you need to initialise it separately)
|
||||
@@ -38,7 +35,6 @@ We do not offer support for self-hosted bots, the code is provided as is without
|
||||
|
||||
## Features
|
||||
|
||||
|
||||
- **Students Cards and Statistics**
|
||||
|
||||
Allow users to create their own private student profile cards and set customs study field tags by using `!stats` and `!setprofile`
|
||||
@@ -108,8 +104,8 @@ Punish cheaters, audit-log, welcome message, and so much more using our full-sca
|
||||
|
||||
### Tutorials
|
||||
|
||||
A command list and general documentation for StudyLion may be found using the `!help` command, and documentation for a specific command, e.g. `config`, may be found with `!help config`.
|
||||
A command list and general documentation for LionBot may be found using the `!help` command, and documentation for a specific command, e.g. `config`, may be found with `!help config`.
|
||||
|
||||
Make sure to check the [full documentation](https://www.notion.so/izabellakis/StudyLion-Bot-Tutorials-f493268fcd12436c9674afef2e151707 "StudyLion Tutorial") to stay updated.
|
||||
Make sure to check the [full documentation](https://www.notion.so/izabellakis/LionBot-Bot-Tutorials-f493268fcd12436c9674afef2e151707 "LionBot Tutorial") to stay updated.
|
||||
|
||||
<a href="https://imgur.com/ziPdJGw"><img src="https://i.imgur.com/ziPdJGws.png" title="source: imgur.com" /></a>
|
||||
|
||||
@@ -12,10 +12,12 @@ ALSO_READ = config/emojis.conf, config/secrets.conf, config/gui.conf
|
||||
asset_path = assets
|
||||
|
||||
support_guild =
|
||||
invite_bot =
|
||||
|
||||
|
||||
[ENDPOINTS]
|
||||
guild_log =
|
||||
gem_transaction =
|
||||
gem_log =
|
||||
|
||||
[LOGGING]
|
||||
log_file = bot.log
|
||||
@@ -50,3 +52,8 @@ domains = base, wards, schedule, shop, moderation, economy, user_config, config,
|
||||
[TEXT_TRACKER]
|
||||
batchsize = 1
|
||||
batchtime = 600
|
||||
|
||||
[TOPGG]
|
||||
enabled = false
|
||||
route = /dbl
|
||||
port = 5000
|
||||
|
||||
@@ -4,3 +4,6 @@ token =
|
||||
[DATA]
|
||||
args = dbname=lion_data
|
||||
appid = StudyLion
|
||||
|
||||
[TOPGG]
|
||||
auth =
|
||||
|
||||
7
data/migration/v13-v14/migration.sql
Normal file
7
data/migration/v13-v14/migration.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE bot_config ADD COLUMN sponsor_prompt TEXT;
|
||||
ALTER TABLE bot_config ADD COLUMN sponsor_message TEXT;
|
||||
|
||||
INSERT INTO VersionHistory (version, author) VALUES (14, 'v13-v14 migration');
|
||||
COMMIT;
|
||||
@@ -4,7 +4,7 @@ CREATE TABLE VersionHistory(
|
||||
time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
author TEXT
|
||||
);
|
||||
INSERT INTO VersionHistory (version, author) VALUES (13, 'Initial Creation');
|
||||
INSERT INTO VersionHistory (version, author) VALUES (14, 'Initial Creation');
|
||||
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_timestamp_column()
|
||||
@@ -17,17 +17,6 @@ $$ language 'plpgsql';
|
||||
-- }}}
|
||||
|
||||
-- App metadata {{{
|
||||
CREATE TABLE AppData(
|
||||
appid TEXT PRIMARY KEY,
|
||||
last_study_badge_scan TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE AppConfig(
|
||||
appid TEXT,
|
||||
key TEXT,
|
||||
value TEXT,
|
||||
PRIMARY KEY(appid, key)
|
||||
);
|
||||
|
||||
CREATE TABLE global_user_blacklist(
|
||||
userid BIGINT PRIMARY KEY,
|
||||
@@ -50,6 +39,8 @@ CREATE TABLE app_config(
|
||||
|
||||
CREATE TABLE bot_config(
|
||||
appname TEXT PRIMARY KEY REFERENCES app_config(appname) ON DELETE CASCADE,
|
||||
sponsor_prompt TEXT,
|
||||
sponsor_message TEXT,
|
||||
default_skin TEXT
|
||||
);
|
||||
|
||||
@@ -1227,7 +1218,7 @@ create TABLE timers(
|
||||
inactivity_threshold INTEGER,
|
||||
channel_name TEXT,
|
||||
pretty_name TEXT,
|
||||
owenrid BIGINT REFERENCES user_config,
|
||||
ownerid BIGINT REFERENCES user_config,
|
||||
manager_roleid BIGINT,
|
||||
last_messageid BIGINT,
|
||||
voice_alerts BOOLEAN,
|
||||
|
||||
@@ -9,3 +9,5 @@ topggpy
|
||||
psutil
|
||||
pillow
|
||||
python-dateutil
|
||||
bidict
|
||||
frozendict
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user