(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.
This commit is contained in:
2021-09-29 23:26:14 +03:00
parent ca442ea319
commit 6a2da9c483
7 changed files with 487 additions and 0 deletions

View File

@@ -5,3 +5,5 @@ from . import admin
from . import tickets from . import tickets
from . import video from . import video
from . import commands

View File

@@ -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 <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 <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("<t:{timestamp:.0f}:d>")
# Ticket type, with current state
if filter_type is None:
if not filter_active:
components.append("`{ticket_type}{ticket_state}`")
else:
components.append("`{ticket_type}`")
elif not filter_active:
components.append("`{ticket_real_state}`")
if not filter_userid:
# Ticket user
components.append("<@{ticket.data.targetid}>")
if filter_userid or (filter_active and filter_type):
# Truncated ticket content
components.append("{content}")
format_str = ' | '.join(components)
# Break tickets into blocks
blocks = [tickets[i:i+10] for i in range(0, len(tickets), 10)]
# Build pages of tickets
ticket_pages = []
for block in blocks:
ticket_page = []
type_len = max(len(type_formatted[ticket.type]) for ticket in block)
state_len = max(len(state_formatted[ticket.state]) for ticket in block)
for ticket in block:
# First truncate content if required
content = ticket.data.content
if len(content) > 40:
content = content[:37] + '...'
# Build ticket line
line = format_str.format(
ticket=ticket,
timestamp=ticket.data.created_at.timestamp(),
ticket_type=type_formatted[ticket.type],
type_len=type_len,
ticket_state=" [{}]".format(state_formatted[ticket.state]) if ticket.state != TicketState.OPEN else '',
ticket_real_state=state_formatted[ticket.state],
state_len=state_len,
content=content
)
if ticket.state == TicketState.PARDONED:
line = "~~{}~~".format(line)
# Add to current page
ticket_page.append(line)
# Combine lines and add page to pages
ticket_pages.append('\n'.join(ticket_page))
# Build 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

View File

@@ -32,6 +32,7 @@ class TicketState(FieldEnum):
EXPIRING = 'EXPIRING', "Active" EXPIRING = 'EXPIRING', "Active"
EXPIRED = 'EXPIRED', "Expired" EXPIRED = 'EXPIRED', "Expired"
PARDONED = 'PARDONED', "Pardoned" PARDONED = 'PARDONED', "Pardoned"
REVERTED = 'REVERTED', "Reverted"
class Ticket: class Ticket:
@@ -200,6 +201,10 @@ class Ticket:
def state(self): def state(self):
return TicketState(self.data.ticket_state) return TicketState(self.data.ticket_state)
@property
def type(self):
return TicketType(self.data.ticket_type)
async def update(self, **kwargs): async def update(self, **kwargs):
""" """
Update ticket fields. Update ticket fields.

View File

@@ -1,2 +1,3 @@
from .Ticket import Ticket, TicketType, TicketState from .Ticket import Ticket, TicketType, TicketState
from .studybans import StudyBanTicket from .studybans import StudyBanTicket
from .notes import NoteTicket

View File

@@ -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 <content>
Description:
Add a note to the target's moderation record.
The note will appear in the moderation log and in the `tickets` command.
The `target` must be specificed by mention or user id.
If the `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 <content>`.".format(ctx.best_prefix)
)
# Extract the target. We don't require them to be in the server
splits = ctx.args.split(maxsplit=1)
target_str = splits[0].strip('<@!&> ')
if not target_str.isdigit():
return await ctx.error_reply(
"**Usage:** `{}note @target <content>`.\n"
"`target` must be provided by mention or userid.".format(ctx.best_prefix)
)
targetid = int(target_str)
# Extract or prompt for the content
if len(splits) != 2:
try:
content = await ctx.input("What note would you like to add?", timeout=300)
except ResponseTimedOut:
raise ResponseTimedOut("Prompt timed out, no note was created.")
else:
content = splits[1].strip()
# Create the note ticket
ticket = await NoteTicket.create(
ctx.guild.id,
targetid,
ctx.author.id,
content
)
if ticket.data.log_msg_id:
await ctx.embed_reply(
"Note on <@{}> created as [Ticket #{}]({}).".format(
targetid,
ticket.data.guild_ticketid,
ticket.link
)
)
else:
await ctx.embed_reply(
"Note on <@{}> created as Ticket #{}.".format(targetid, ticket.data.guild_ticketid)
)

View File

@@ -1,8 +1,10 @@
import asyncio
import discord import discord
from cmdClient import Context from cmdClient import Context
from data import tables from data import tables
from core import Lion from core import Lion
from . import lib
from settings import GuildSettings, UserSettings from settings import GuildSettings, UserSettings
@@ -43,6 +45,85 @@ async def error_reply(ctx, error_str, **kwargs):
return message 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): def context_property(func):
setattr(Context, func.__name__, property(func)) setattr(Context, func.__name__, property(func))
return func return func

View File

@@ -22,3 +22,14 @@ def is_guild_admin(member):
) )
async def guild_admin(ctx, *args, **kwargs): async def guild_admin(ctx, *args, **kwargs):
return is_guild_admin(ctx.author) 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))