(moderation): pardon command and warn tickets.
This commit is contained in:
@@ -5,6 +5,7 @@ import asyncio
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
import discord
|
import discord
|
||||||
|
|
||||||
|
from cmdClient.lib import ResponseTimedOut
|
||||||
from wards import guild_moderator
|
from wards import guild_moderator
|
||||||
|
|
||||||
from .module import module
|
from .module import module
|
||||||
@@ -41,6 +42,14 @@ state_formatted = {
|
|||||||
TicketState.PARDONED: 'PARDONED'
|
TicketState.PARDONED: 'PARDONED'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state_summary_formatted = {
|
||||||
|
TicketState.OPEN: 'Active',
|
||||||
|
TicketState.EXPIRING: 'Temporary',
|
||||||
|
TicketState.EXPIRED: 'Expired',
|
||||||
|
TicketState.REVERTED: 'Manually Reverted',
|
||||||
|
TicketState.PARDONED: 'Pardoned'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@module.cmd(
|
@module.cmd(
|
||||||
"tickets",
|
"tickets",
|
||||||
@@ -57,12 +66,18 @@ async def cmd_tickets(ctx, flags):
|
|||||||
Display and optionally filter the moderation event history in this guild.
|
Display and optionally filter the moderation event history in this guild.
|
||||||
Flags::
|
Flags::
|
||||||
type: Filter by ticket type. See **Ticket Types** below.
|
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::
|
Ticket Types::
|
||||||
note: Moderation notes.
|
note: Moderation notes.
|
||||||
warn: Moderation warnings, both manual and automatic.
|
warn: Moderation warnings, both manual and automatic.
|
||||||
studyban: Bans from using study features from abusing the study system.
|
studyban: Bans from using study features from abusing the study system.
|
||||||
blacklist: Complete blacklisting from using my commands.
|
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:
|
Examples:
|
||||||
{prefix}tickets {ctx.guild.owner.mention} --type warn --active
|
{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
|
# Combine lines and add page to pages
|
||||||
ticket_pages.append('\n'.join(ticket_page))
|
ticket_pages.append('\n'.join(ticket_page))
|
||||||
|
|
||||||
# Build summary
|
# Build active ticket type summary
|
||||||
freq = defaultdict(int)
|
freq = defaultdict(int)
|
||||||
for ticket in tickets:
|
for ticket in tickets:
|
||||||
if ticket.state != TicketState.PARDONED:
|
if ticket.state != TicketState.PARDONED:
|
||||||
@@ -181,10 +196,22 @@ async def cmd_tickets(ctx, flags):
|
|||||||
]
|
]
|
||||||
summary_pairs.sort(key=lambda pair: pair[0])
|
summary_pairs.sort(key=lambda pair: pair[0])
|
||||||
# num_len = max(len(str(num)) for num in freq.values())
|
# num_len = max(len(str(num)) for num in freq.values())
|
||||||
# summary = '\n'.join(
|
# type_summary = '\n'.join(
|
||||||
# "**{}** {}".format(*pair)
|
# "**`{:<{}}`** {}".format(pair[0], num_len, pair[1])
|
||||||
# for pair in summary_pairs
|
# 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 = [
|
summary_strings = [
|
||||||
"**`{}`** {}".format(*pair) for pair in summary_pairs
|
"**`{}`** {}".format(*pair) for pair in summary_pairs
|
||||||
]
|
]
|
||||||
@@ -272,3 +299,144 @@ async def _ticket_display(ctx, ticket_map):
|
|||||||
await current_ticket_msg.delete()
|
await current_ticket_msg.delete()
|
||||||
except discord.HTTPException:
|
except discord.HTTPException:
|
||||||
pass
|
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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
from .Ticket import Ticket, TicketType, TicketState
|
from .Ticket import Ticket, TicketType, TicketState
|
||||||
from .studybans import StudyBanTicket
|
from .studybans import StudyBanTicket
|
||||||
from .notes import NoteTicket
|
from .notes import NoteTicket
|
||||||
|
from .warns import WarnTicket
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ class NoteTicket(Ticket):
|
|||||||
return ticket
|
return ticket
|
||||||
|
|
||||||
|
|
||||||
# TODO: Remember the moderator ward!!
|
|
||||||
@module.cmd(
|
@module.cmd(
|
||||||
"note",
|
"note",
|
||||||
group="Moderation",
|
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 note will appear in the moderation log and in the `tickets` command.
|
||||||
|
|
||||||
The `target` must be specificed by mention or user id.
|
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:
|
Example:
|
||||||
{prefix}note {ctx.author.mention} Seen reading the `note` documentation.
|
{prefix}note {ctx.author.mention} Seen reading the `note` documentation.
|
||||||
"""
|
"""
|
||||||
|
|||||||
154
bot/modules/moderation/tickets/warns.py
Normal file
154
bot/modules/moderation/tickets/warns.py
Normal 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 ''
|
||||||
|
)
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user