diff --git a/bot/modules/moderation/commands.py b/bot/modules/moderation/commands.py index e360a3ed..0401d951 100644 --- a/bot/modules/moderation/commands.py +++ b/bot/modules/moderation/commands.py @@ -5,6 +5,7 @@ import asyncio from collections import defaultdict import discord +from cmdClient.lib import ResponseTimedOut from wards import guild_moderator from .module import module @@ -41,6 +42,14 @@ state_formatted = { 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", @@ -57,12 +66,18 @@ async def cmd_tickets(ctx, flags): Display and optionally filter the moderation event history in this guild. Flags:: type: Filter by ticket type. See **Ticket Types** below. - active: Only show active tickets (i.e. hide expired and pardoned ones). + 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 """ @@ -170,7 +185,7 @@ async def cmd_tickets(ctx, flags): # Combine lines and add page to pages ticket_pages.append('\n'.join(ticket_page)) - # Build summary + # Build active ticket type summary freq = defaultdict(int) for ticket in tickets: if ticket.state != TicketState.PARDONED: @@ -181,10 +196,22 @@ async def cmd_tickets(ctx, flags): ] summary_pairs.sort(key=lambda pair: pair[0]) # num_len = max(len(str(num)) for num in freq.values()) - # summary = '\n'.join( - # "**{}** {}".format(*pair) + # 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 ] @@ -272,3 +299,144 @@ async def _ticket_display(ctx, ticket_map): 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 = [] + 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/bot/modules/moderation/tickets/__init__.py b/bot/modules/moderation/tickets/__init__.py index 207884bb..f9a05faa 100644 --- a/bot/modules/moderation/tickets/__init__.py +++ b/bot/modules/moderation/tickets/__init__.py @@ -1,3 +1,4 @@ from .Ticket import Ticket, TicketType, TicketState from .studybans import StudyBanTicket from .notes import NoteTicket +from .warns import WarnTicket diff --git a/bot/modules/moderation/tickets/notes.py b/bot/modules/moderation/tickets/notes.py index 9eaad3fb..7f8ec1e9 100644 --- a/bot/modules/moderation/tickets/notes.py +++ b/bot/modules/moderation/tickets/notes.py @@ -46,7 +46,6 @@ class NoteTicket(Ticket): return ticket -# TODO: Remember the moderator ward!! @module.cmd( "note", group="Moderation", @@ -63,7 +62,7 @@ async def cmd_note(ctx): 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 `note` is not given, it will be prompted for. + If the `content` is not given, it will be prompted for. Example: {prefix}note {ctx.author.mention} Seen reading the `note` documentation. """ diff --git a/bot/modules/moderation/tickets/warns.py b/bot/modules/moderation/tickets/warns.py new file mode 100644 index 00000000..7d574679 --- /dev/null +++ b/bot/modules/moderation/tickets/warns.py @@ -0,0 +1,154 @@ +""" +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, + auto=False, + 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 '' + ) + )