Merge branch 'rewrite' into pillow

This commit is contained in:
2023-11-08 14:16:12 +02:00
149 changed files with 9721 additions and 3061 deletions

View File

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

View File

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

View File

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

View File

@@ -4,3 +4,6 @@ token =
[DATA]
args = dbname=lion_data
appid = StudyLion
[TOPGG]
auth =

View 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;

View File

@@ -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,

View File

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

View File

@@ -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,

View File

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

View File

@@ -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",

View File

@@ -1,5 +1,5 @@
CONFIG_FILE = "config/bot.conf"
DATA_VERSION = 13
DATA_VERSION = 14
MAX_COINS = 2147483647 - 1

View File

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

View File

@@ -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.
"""

View File

@@ -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):
"""

Submodule src/gui updated: f2760218ef...c1bcb05c25

View File

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

View File

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

View File

@@ -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,

View File

@@ -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(

View File

@@ -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",

View File

@@ -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,

View File

@@ -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(

View File

@@ -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",

View File

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

View File

@@ -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(

View File

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

View File

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

View File

@@ -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.

View File

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

View File

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

View File

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

View File

@@ -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",

View File

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

View File

@@ -0,0 +1,2 @@
from .note import NoteTicket
from .warning import WarnTicket

View 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

View 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

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

View File

@@ -1,7 +0,0 @@
# flake8: noqa
from .module import module
from . import help
from . import links
from . import nerd
from . import join_message

View File

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

View File

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

View File

@@ -1,5 +0,0 @@
guide_link = "https://discord.studylions.com/tutorial"
animation_link = (
"https://media.discordapp.net/attachments/879412267731542047/926837189814419486/ezgif.com-resize.gif"
)

View File

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

View File

@@ -1,3 +0,0 @@
from LionModule import LionModule
module = LionModule("Meta")

View File

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

View File

@@ -1,9 +0,0 @@
from .module import module
from . import data
from . import admin
from . import tickets
from . import video
from . import commands

View File

@@ -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."

View File

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

View File

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

View File

@@ -1,4 +0,0 @@
from cmdClient import Module
module = Module("Moderation")

View File

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

View File

@@ -1,4 +0,0 @@
from .Ticket import Ticket, TicketType, TicketState
from .studybans import StudyBanTicket
from .notes import NoteTicket
from .warns import WarnTicket

View File

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

View File

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

View File

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

View File

@@ -1,5 +0,0 @@
from . import module
from . import data
from . import config
from . import commands

View File

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

View File

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

View File

@@ -1,4 +0,0 @@
from data import Table
guild_whitelist = Table("sponsor_guild_whitelist")

View File

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

View File

@@ -1,6 +0,0 @@
from .module import module
from . import webhook
from . import commands
from . import data
from . import settings

View File

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

View File

@@ -1,9 +0,0 @@
from data.interfaces import RowTable, Table
topggvotes = RowTable(
'topgg',
('voteid', 'userid', 'boostedTimestamp'),
'voteid'
)
guild_whitelist = Table('topgg_guild_whitelist')

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +0,0 @@
from .module import module
from . import admin
from . import data
from . import tracker

View File

@@ -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."

View File

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

View File

@@ -1,4 +0,0 @@
from LionModule import LionModule
module = LionModule("Workout")

View File

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

View File

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

View File

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

View File

@@ -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(

View File

@@ -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",

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,

View File

@@ -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(

View File

@@ -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",

View File

@@ -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',

View 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

View File

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

View File

@@ -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,

View File

@@ -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(

View File

@@ -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",

View File

@@ -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,

View File

@@ -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(

View File

@@ -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!*"
)

View File

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

View File

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

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

@@ -0,0 +1,388 @@
from typing import Optional
import asyncio
import discord
from discord.ext import commands as cmds
import discord.app_commands as appcmds
from cachetools import LRUCache
from bidict import bidict
from frozendict import frozendict
from meta import LionCog, LionBot, LionContext
from meta.errors import UserInputError
from meta.logger import log_wrap
from utils.lib import MISSING, utc_now
from wards import sys_admin_ward, low_management_ward
from gui.base import AppSkin
from babel.translator import ctx_locale
from . import logger, babel
from .data import CustomSkinData
from .skinlib import appskin_as_choice, FrozenCustomSkin, CustomSkin
from .settings import GlobalSkinSettings
from .settingui import GlobalSkinSettingUI
from .userskinui import UserSkinUI
from .editor.skineditor import CustomSkinEditor
_p = babel._p
class CustomSkinCog(LionCog):
def __init__(self, bot: LionBot):
self.bot = bot
self.data: CustomSkinData = bot.db.load_registry(CustomSkinData())
self.bot_settings = GlobalSkinSettings()
# Cache of app skin id -> app skin name
# After initialisation, contains all the base skins available for this app
self.appskin_names: bidict[int, str] = bidict()
# Bijective cache of skin property ids <-> (card_id, property_name) tuples
self.skin_properties: bidict[int, tuple[str, str]] = bidict()
# Cache of currently active user skins
# Invalidation handled by local event handler
self.active_user_skinids: LRUCache[int, Optional[int]] = LRUCache(maxsize=5000)
# Cache of custom skin id -> frozen custom skin
self.custom_skins: LRUCache[int, FrozenCustomSkin] = LRUCache(maxsize=1000)
self.current_default: Optional[str] = None
async def cog_load(self):
await self.data.init()
if (leo_setting_cog := self.bot.get_cog('LeoSettings')) is not None:
leo_setting_cog.bot_setting_groups.append(self.bot_settings)
self.crossload_group(self.leo_group, leo_setting_cog.leo_group)
if (config_cog := self.bot.get_cog('ConfigCog')) is not None:
self.crossload_group(self.admin_group, config_cog.admin_group)
if (user_cog := self.bot.get_cog('UserConfigCog')) is not None:
self.crossload_group(self.my_group, user_cog.userconfig_group)
await self._reload_appskins()
await self._reload_property_map()
await self.get_default_skin()
async def _reload_property_map(self):
"""
Reload the skin property id to (card_id, property_name) bijection.
"""
records = await self.data.skin_property_map.select_where()
cache = self.skin_properties
cache.clear()
for record in records:
cache[record['property_id']] = (record['card_id'], record['property_name'])
logger.info(
f"Loaded '{len(cache)}' custom skin properties."
)
async def _reload_appskins(self):
"""
Reload the global_available_skin id to the appskin name.
Create global_available_skins that don't already exist.
"""
cache = self.appskin_names
available = list(AppSkin.skins_data['skin_map'].keys())
rows = await self.data.GlobalSkin.fetch_where(skin_name=available)
cache.clear()
for row in rows:
cache[row.skin_id] = row.skin_name
# Not caring about efficiency here because this essentially needs to happen once ever
missing = [name for name in available if name not in cache.values()]
for name in missing:
row = await self.data.GlobalSkin.create(skin_name=name)
cache[row.skin_id] = row.skin_name
logger.info(
f"Loaded '{len(cache)}' global base skins."
)
# ----- Internal API -----
def get_base(self, base_skin_id: int) -> AppSkin:
"""
Initialise a localised AppSkin for the given base skin id.
"""
if base_skin_id not in self.appskin_names:
raise ValueError(f"Unknown app skin id '{base_skin_id}'")
return AppSkin.get(
skin_id=self.appskin_names[base_skin_id],
locale=ctx_locale.get(),
use_cache=True,
)
async def get_default_skin(self) -> Optional[str]:
"""
Get the current app-default skin, and return it as a skin name.
May be None if there is no app-default set.
This should almost always hit cache.
"""
setting = self.bot_settings.DefaultSkin
instance = await setting.get(self.bot.appname)
self.current_default = instance.value
return instance.value
async def fetch_property_ids(self, *card_properties: tuple[str, str]) -> list[int]:
"""
Fetch the skin property ids for the given (card_id, property_name) tuples.
Creates any missing properties.
"""
mapper = self.skin_properties.inverse
missing = [prop for prop in card_properties if prop not in mapper]
if missing:
# First insert missing properties
await self.data.skin_property_map.insert_many(
('card_id', 'property_name'),
*missing
)
await self._reload_property_map()
return [mapper[prop] for prop in card_properties]
async def get_guild_skinid(self, guildid: int) -> Optional[int]:
"""
Fetch the custom_skin_id associated to the current guild.
Returns None if the guild is not premium or has no custom skin set.
Usually hits cache (Specifically the PremiumGuild cache).
"""
cog = self.bot.get_cog('PremiumCog')
if not cog:
logger.error(
"Trying to get guild skinid without loaded premium cog!"
)
return None
row = await cog.data.PremiumGuild.fetch(guildid)
return row.custom_skin_id if row else None
async def get_user_skinid(self, userid: int) -> Optional[int]:
"""
Fetch the custom_skin_id of the active skin in the given user's skin inventory.
Returns None if the user does not have an active skin.
Should usually be cached by `self.active_user_skinids`.
"""
skinid = self.active_user_skinids.get(userid, MISSING)
if skinid is MISSING:
rows = await self.data.UserSkin.fetch_where(userid=userid, active=True)
skinid = rows[0].custom_skin_id if rows else None
self.active_user_skinids[userid] = skinid
return skinid
async def args_for_skin(self, skinid: int, cardid: str) -> dict[str, str]:
"""
Fetch the skin argument dictionary for the given custom_skin_id.
Should usually be cached by `self.custom_skin_args`.
"""
skin = self.custom_skins.get(skinid, None)
if skin is None:
custom_skin = await CustomSkin.fetch(self.bot, skinid)
skin = custom_skin.freeze()
self.custom_skins[skinid] = skin
return skin.args_for(cardid)
# ----- External API -----
async def get_skinargs_for(self,
guildid: Optional[int], userid: Optional[int], card_id: str
) -> dict[str, str]:
"""
Get skin arguments for a standard GUI render with the given guild, user, and for the given card.
Takes into account the global defaults, guild custom skin, and user active skin.
"""
args = {}
if userid and (skinid := await self.get_user_skinid(userid)):
skin_args = await self.args_for_skin(skinid, card_id)
args.update(skin_args)
elif guildid and (skinid := await self.get_guild_skinid(guildid)):
skin_args = await self.args_for_skin(skinid, card_id)
args.update(skin_args)
default = self.current_default
if default:
args.setdefault("base_skin_id", default)
return args
# ----- Event Handlers -----
@LionCog.listener('on_userset_skin')
async def refresh_user_skin(self, userid: int):
"""
Update cached user active skinid.
"""
self.active_user_skinids.pop(userid, None)
await self.get_user_skinid(userid)
@LionCog.listener('on_skin_updated')
async def refresh_custom_skin(self, skinid: int):
"""
Update cached args for given custom skin id.
"""
self.custom_skins.pop(skinid, None)
custom_skin = await CustomSkin.fetch(self.bot, skinid)
if custom_skin is not None:
skin = custom_skin.freeze()
self.custom_skins[skinid] = skin
@LionCog.listener('on_botset_skin')
async def handle_botset_skin(self, appname, instance):
await self.bot.global_dispatch('global_botset_skin', appname)
@LionCog.listener('on_global_botset_skin')
async def refresh_default_skin(self, appname):
await self.bot.core.data.BotConfig.fetch(appname, cached=False)
await self.get_default_skin()
# ----- Userspace commands -----
@LionCog.placeholder_group
@cmds.hybrid_group("my", with_app_command=False)
async def my_group(self, ctx: LionContext):
...
@my_group.command(
name=_p('cmd:my_skin', "skin"),
description=_p(
'cmd:my_skin|desc',
"Change the colours of your interface"
)
)
async def cmd_my_skin(self, ctx: LionContext):
if not ctx.interaction:
return
ui = UserSkinUI(self.bot, ctx.author.id, ctx.author.id)
await ui.run(ctx.interaction, ephemeral=True)
await ui.wait()
# ----- Adminspace commands -----
@LionCog.placeholder_group
@cmds.hybrid_group("admin", with_app_command=False)
async def admin_group(self, ctx: LionContext):
...
@admin_group.command(
name=_p('cmd:admin_brand', "brand"),
description=_p(
'cmd:admin_brand|desc',
"Fully customise my default interface for your members!"
)
)
@low_management_ward
async def cmd_admin_brand(self, ctx: LionContext):
if not ctx.interaction:
return
if not ctx.guild:
return
t = self.bot.translator.t
# Check guild premium status
premiumcog = self.bot.get_cog('PremiumCog')
guild_row = await premiumcog.data.PremiumGuild.fetch(ctx.guild.id, cached=False)
if not guild_row:
raise UserInputError(
t(_p(
'cmd:admin_brand|error:not_premium',
"Only premium servers can modify their interface theme! "
"Use the {premium} command to upgrade your server."
)).format(premium=self.bot.core.mention_cmd('premium'))
)
await ctx.interaction.response.defer(thinking=True, ephemeral=False)
if guild_row.custom_skin_id is None:
# Create new custom skin
skin_data = await self.data.CustomisedSkin.create(
base_skin_id=self.appskin_names.inverse[self.current_default] if self.current_default else None
)
await guild_row.update(custom_skin_id=skin_data.custom_skin_id)
skinid = guild_row.custom_skin_id
custom_skin = await CustomSkin.fetch(self.bot, skinid)
if custom_skin is None:
raise ValueError("Invalid custom skin id")
# Open the CustomSkinEditor with this skin
ui = CustomSkinEditor(custom_skin, callerid=ctx.author.id)
await ui.send(ctx.channel)
await ctx.interaction.delete_original_response()
await ui.wait()
# ----- Owner commands -----
@LionCog.placeholder_group
@cmds.hybrid_group("leo", with_app_command=False)
async def leo_group(self, ctx: LionContext):
...
@leo_group.command(
name=_p('cmd:leo_skin', "skin"),
description=_p(
'cmd:leo_skin|desc',
"View and update the global skin settings"
)
)
@appcmds.rename(
default_skin=_p('cmd:leo_skin|param:default_skin', "default_skin"),
)
@appcmds.describe(
default_skin=_p(
'cmd:leo_skin|param:default_skin|desc',
"Set the global default skin."
)
)
@sys_admin_ward
async def cmd_leo_skin(self, ctx: LionContext,
default_skin: Optional[str] = None):
if not ctx.interaction:
return
await ctx.interaction.response.defer(thinking=True)
modified = []
if default_skin is not None:
setting = self.bot_settings.DefaultSkin
instance = await setting.from_string(self.bot.appname, default_skin)
modified.append(instance)
for instance in modified:
await instance.write()
# No update_str, just show the config window
ui = GlobalSkinSettingUI(self.bot, self.bot.appname, ctx.channel.id)
await ui.run(ctx.interaction)
await ui.wait()
@cmd_leo_skin.autocomplete('default_skin')
async def cmd_leo_skin_acmpl_default_skin(self, interaction: discord.Interaction, partial: str):
babel = self.bot.get_cog('BabelCog')
ctx_locale.set(await babel.get_user_locale(interaction.user.id))
choices = []
for skinid in self.appskin_names:
appskin = self.get_base(skinid)
match = partial.lower()
if match in appskin.skin_id.lower() or match in appskin.display_name.lower():
choices.append(appskin_as_choice(appskin))
if not choices:
t = self.bot.translator.t
choices = [
appcmds.Choice(
name=t(_p(
'cmd:leo_skin|acmpl:default_skin|error:no_match',
"No app skins matching {partial}"
)).format(partial=partial)[:100],
value=partial
)
]
return choices

117
src/modules/skins/data.py Normal file
View File

@@ -0,0 +1,117 @@
from data import Registry, RowModel, Table
from data.columns import Integer, Bool, Timestamp, String
class CustomSkinData(Registry):
class GlobalSkin(RowModel):
"""
Schema
------
CREATE TABLE global_available_skins(
skin_id SERIAL PRIMARY KEY,
skin_name TEXT NOT NULL
);
CREATE INDEX global_available_skin_names ON global_available_skins (skin_name);
"""
_tablename_ = 'global_available_skins'
_cache_ = {}
skin_id = Integer(primary=True)
skin_name = String()
class CustomisedSkin(RowModel):
"""
Schema
------
CREATE TABLE customised_skins(
custom_skin_id SERIAL PRIMARY KEY,
base_skin_id INTEGER REFERENCES global_available_skins (skin_id),
_timestamp TIMESTAMPTZ DEFAULT now()
);
"""
_tablename_ = 'customised_skins'
custom_skin_id = Integer(primary=True)
base_skin_id = Integer()
_timestamp = Timestamp()
"""
Schema
------
CREATE TABLE customised_skin_property_ids(
property_id SERIAL PRIMARY KEY,
card_id TEXT NOT NULL,
property_name TEXT NOT NULL,
UNIQUE(card_id, property_name)
);
"""
skin_property_map = Table('customised_skin_property_ids')
"""
Schema
------
CREATE TABLE customised_skin_properties(
custom_skin_id INTEGER NOT NULL REFERENCES customised_skins (custom_skin_id),
property_id INTEGER NOT NULL REFERENCES customised_skin_property_ids (property_id),
value TEXT NOT NULL,
PRIMARY KEY (custom_skin_id, property_id)
);
CREATE INDEX customised_skin_property_skin_id ON customised_skin_properties(custom_skin_id);
"""
skin_properties = Table('customised_skin_properties')
"""
Schema
------
CREATE VIEW customised_skin_data AS
SELECT
skins.custom_skin_id AS custom_skin_id,
skins.base_skin_id AS base_skin_id,
properties.property_id AS property_id,
prop_ids.card_id AS card_id,
prop_ids.property_name AS property_name,
properties.value AS value
FROM
customised_skins skins
LEFT JOIN customised_skin_properties properties ON skins.custom_skin_id = properties.custom_skin_id
LEFT JOIN customised_skin_property_ids prop_ids ON properties.property_id = prop_ids.property_id;
"""
custom_skin_info = Table('customised_skin_data')
class UserSkin(RowModel):
"""
Schema
------
CREATE TABLE user_skin_inventory(
itemid SERIAL PRIMARY KEY,
userid BIGINT NOT NULL REFERENCES user_config (userid) ON DELETE CASCADE,
custom_skin_id INTEGER NOT NULL REFERENCES customised_skins (custom_skin_id) ON DELETE CASCADE,
transactionid INTEGER REFERENCES gem_transactions (transactionid),
active BOOLEAN NOT NULL DEFAULT FALSE,
acquired_at TIMESTAMPTZ DEFAULT now(),
expires_at TIMESTAMPTZ
);
CREATE INDEX user_skin_inventory_users ON user_skin_inventory(userid);
CREATE UNIQUE INDEX user_skin_inventory_active ON user_skin_inventory(userid) WHERE active = TRUE;
"""
_tablename_ = 'user_skin_inventory'
itemid = Integer(primary=True)
userid = Integer()
custom_skin_id = Integer()
transactionid = Integer()
active = Bool()
acquired_at = Timestamp()
expires_at = Timestamp()
"""
Schema
------
CREATE VIEW user_active_skins AS
SELECT
*
FROM user_skin_inventory
WHERE active=True;
"""
user_active_skins = Table('user_active_skins')

View File

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More