(moderation): pardon command and warn tickets.

This commit is contained in:
2021-09-30 20:51:49 +03:00
parent 6a2da9c483
commit 3ab3721c99
4 changed files with 328 additions and 6 deletions

View File

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

View File

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

View File

@@ -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.
"""

View File

@@ -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 <reason>
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 <reason>`.".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 <reason>`.\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 ''
)
)