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