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