From 6a2da9c483bea8ea8eb80b9c09f3f358b791cc56 Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 29 Sep 2021 23:26:14 +0300 Subject: [PATCH] (moderation): `tickets` command and `note` ticket. Add new `tickets` command for viewing moderation tickets. Add `Note` ticket implementation, with command. Add `offer_delete` context utility. Add `guild_moderator` command ward. --- bot/modules/moderation/__init__.py | 2 + bot/modules/moderation/commands.py | 274 +++++++++++++++++++++ bot/modules/moderation/tickets/Ticket.py | 5 + bot/modules/moderation/tickets/__init__.py | 1 + bot/modules/moderation/tickets/notes.py | 113 +++++++++ bot/utils/ctx_addons.py | 81 ++++++ bot/wards.py | 11 + 7 files changed, 487 insertions(+) create mode 100644 bot/modules/moderation/commands.py create mode 100644 bot/modules/moderation/tickets/notes.py diff --git a/bot/modules/moderation/__init__.py b/bot/modules/moderation/__init__.py index 0c0b40d0..e1cc7d79 100644 --- a/bot/modules/moderation/__init__.py +++ b/bot/modules/moderation/__init__.py @@ -5,3 +5,5 @@ from . import admin from . import tickets from . import video + +from . import commands diff --git a/bot/modules/moderation/commands.py b/bot/modules/moderation/commands.py new file mode 100644 index 00000000..e360a3ed --- /dev/null +++ b/bot/modules/moderation/commands.py @@ -0,0 +1,274 @@ +""" +Shared commands for the moderation module. +""" +import asyncio +from collections import defaultdict +import discord + +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' +} + + +@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 active 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. + 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 = {} + 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 ctx.embed_reply("There are no tickets with these criteria!") + else: + return 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 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()) + # summary = '\n'.join( + # "**{}** {}".format(*pair) + # for pair in summary_pairs + # ) + 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) + display_task = asyncio.create_task(_ticket_display(ctx, ticket_map)) + ctx.tasks.append(display_task) + await ctx.cancellable(out_msg, add_reaction=False) + + +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) + ) + 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 diff --git a/bot/modules/moderation/tickets/Ticket.py b/bot/modules/moderation/tickets/Ticket.py index 1d67f12c..3193a185 100644 --- a/bot/modules/moderation/tickets/Ticket.py +++ b/bot/modules/moderation/tickets/Ticket.py @@ -32,6 +32,7 @@ class TicketState(FieldEnum): EXPIRING = 'EXPIRING', "Active" EXPIRED = 'EXPIRED', "Expired" PARDONED = 'PARDONED', "Pardoned" + REVERTED = 'REVERTED', "Reverted" class Ticket: @@ -200,6 +201,10 @@ class Ticket: 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. diff --git a/bot/modules/moderation/tickets/__init__.py b/bot/modules/moderation/tickets/__init__.py index 5424a455..207884bb 100644 --- a/bot/modules/moderation/tickets/__init__.py +++ b/bot/modules/moderation/tickets/__init__.py @@ -1,2 +1,3 @@ from .Ticket import Ticket, TicketType, TicketState from .studybans import StudyBanTicket +from .notes import NoteTicket diff --git a/bot/modules/moderation/tickets/notes.py b/bot/modules/moderation/tickets/notes.py new file mode 100644 index 00000000..9eaad3fb --- /dev/null +++ b/bot/modules/moderation/tickets/notes.py @@ -0,0 +1,113 @@ +""" +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 + + +# TODO: Remember the moderator ward!! +@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 `note` 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/bot/utils/ctx_addons.py b/bot/utils/ctx_addons.py index 6dc3e0b9..e88ca1b8 100644 --- a/bot/utils/ctx_addons.py +++ b/bot/utils/ctx_addons.py @@ -1,8 +1,10 @@ +import asyncio import discord from cmdClient import Context from data import tables from core import Lion +from . import lib from settings import GuildSettings, UserSettings @@ -43,6 +45,85 @@ async def error_reply(ctx, error_str, **kwargs): return message +@Context.util +async def offer_delete(ctx: Context, *to_delete, timeout=300): + """ + Offers to delete the provided messages via a reaction on the last message. + Removes the reaction if the offer times out. + + If any exceptions occur, handles them silently and returns. + + Parameters + ---------- + to_delete: List[Message] + The messages to delete. + + timeout: int + Time in seconds after which to remove the delete offer reaction. + """ + # Get the delete emoji from the config + emoji = lib.cross + + # Return if there are no messages to delete + if not to_delete: + return + + # The message to add the reaction to + react_msg = to_delete[-1] + + # Build the reaction check function + if ctx.guild: + modrole = ctx.guild_settings.mod_role.value if ctx.guild else None + + def check(reaction, user): + if not (reaction.message.id == react_msg.id and reaction.emoji == emoji): + return False + if user == ctx.guild.me: + return False + return ((user == ctx.author) + or (user.permissions_in(ctx.ch).manage_messages) + or (modrole and modrole in user.roles)) + else: + def check(reaction, user): + return user == ctx.author and reaction.message.id == react_msg.id and reaction.emoji == emoji + + try: + # Add the reaction to the message + await react_msg.add_reaction(emoji) + + # Wait for the user to press the reaction + reaction, user = await ctx.client.wait_for("reaction_add", check=check, timeout=timeout) + + # Since the check was satisfied, the reaction is correct. Delete the messages, ignoring any exceptions + deleted = False + # First try to bulk delete if we have the permissions + if ctx.guild and ctx.ch.permissions_for(ctx.guild.me).manage_messages: + try: + await ctx.ch.delete_messages(to_delete) + deleted = True + except Exception: + deleted = False + + # If we couldn't bulk delete, delete them one by one + if not deleted: + try: + asyncio.gather(*[message.delete() for message in to_delete], return_exceptions=True) + except Exception: + pass + except (asyncio.TimeoutError, asyncio.CancelledError): + # Timed out waiting for the reaction, attempt to remove the delete reaction + try: + await react_msg.remove_reaction(emoji, ctx.client.user) + except Exception: + pass + except discord.Forbidden: + pass + except discord.NotFound: + pass + except discord.HTTPException: + pass + + def context_property(func): setattr(Context, func.__name__, property(func)) return func diff --git a/bot/wards.py b/bot/wards.py index d867ccbe..09965a46 100644 --- a/bot/wards.py +++ b/bot/wards.py @@ -22,3 +22,14 @@ def is_guild_admin(member): ) async def guild_admin(ctx, *args, **kwargs): return is_guild_admin(ctx.author) + + +@check( + name="MODERATOR", + msg=("You need to be a server moderator to do this!"), + requires=[in_guild], + parents=(guild_admin,) +) +async def guild_moderator(ctx, *args, **kwargs): + modrole = ctx.guild_settings.mod_role.value + return (modrole and (modrole in ctx.author.roles))