feat(moderation): Impl ticket pardon.

This commit is contained in:
2023-10-18 11:54:50 +03:00
parent 948f8da602
commit 68d4f024a8
3 changed files with 160 additions and 12 deletions

View File

@@ -8,11 +8,11 @@ from discord import app_commands as appcmds
from discord.ui.text_input import TextInput, TextStyle
from meta import LionCog, LionBot, LionContext
from meta.errors import SafeCancellation
from meta.errors import SafeCancellation, UserInputError
from meta.logger import log_wrap
from meta.sharding import THIS_SHARD
from core.data import CoreData
from utils.lib import utc_now
from utils.lib import utc_now, parse_ranges
from utils.ui import input
from wards import low_management_ward, high_management_ward, equippable_role, moderator_ward
@@ -352,6 +352,126 @@ class ModerationCog(LionCog):
)
await interaction.edit_original_response(embed=embed)
# Pardon user command
@cmds.hybrid_command(
name=_p('cmd:pardon', "pardon"),
description=_p(
'cmd:pardon|desc',
"Pardon moderation tickets to mark them as no longer in effect."
)
)
@appcmds.rename(
ticketids=_p(
'cmd:pardon|param:ticketids',
"tickets"
),
reason=_p(
'cmd:pardon|param:reason',
"reason"
)
)
@appcmds.describe(
ticketids=_p(
'cmd:pardon|param:ticketids|desc',
"Comma separated list of ticket numbers to pardon."
),
reason=_p(
'cmd:pardon|param:reason',
"Why these tickets are being pardoned."
)
)
@appcmds.default_permissions(manage_guild=True)
@appcmds.guild_only
@moderator_ward
async def cmd_pardon(self, ctx: LionContext,
ticketids: str,
reason: Optional[appcmds.Range[str, 0, 1024]] = None,
):
if not ctx.guild:
return
if not ctx.interaction:
return
t = self.bot.translator.t
# Prompt for pardon reason if not given
# Note we can't parse first since we need to do first response with the modal
if reason is None:
modal_title = t(_p(
'cmd:pardon|modal:reason|title',
"Pardon Tickets"
))
input_field = TextInput(
label=t(_p(
'cmd:pardon|modal:reason|field|label',
"Why are you pardoning these tickets?"
)),
style=TextStyle.long,
min_length=0,
max_length=1024,
)
try:
interaction, reason = await input(
ctx.interaction, modal_title, field=input_field, timeout=300,
)
except asyncio.TimeoutError:
raise SafeCancellation
else:
interaction = ctx.interaction
await interaction.response.defer(thinking=True)
# Parse provided ticketids
try:
parsed_ids = parse_ranges(ticketids)
errored = False
except ValueError:
errored = True
parsed_ids = []
if errored or not parsed_ids:
raise UserInputError(t(_p(
'cmd:pardon|error:parse_ticketids',
"Could not parse provided tickets as a list of ticket ids!"
" Please enter tickets as a comma separated list of ticket numbers,"
" for example `1, 2, 3`."
)))
# Now find these tickets
tickets = await Ticket.fetch_tickets(
bot=self.bot,
guildid=ctx.guild.id,
guild_ticketid=parsed_ids,
)
if not tickets:
raise UserInputError(t(_p(
'cmd:pardon|error:no_matching',
"No matching moderation tickets found to pardon!"
)))
# Pardon each ticket
for ticket in tickets:
await ticket.pardon(
modid=ctx.author.id,
reason=reason
)
# Now ack the pardon
count = len(tickets)
ticketstr = ', '.join(
f"[#{ticket.data.guild_ticketid}]({ticket.jump_url})" for ticket in tickets
)
embed = discord.Embed(
colour=discord.Colour.brand_green(),
description=t(_np(
'cmd:pardon|embed:success|title',
"Ticket {ticketstr} has been pardoned.",
"The following tickets have been pardoned:\n{ticketstr}",
count
)).format(ticketstr=ticketstr)
)
await interaction.edit_original_response(embed=embed)
# ----- Configuration -----
@LionCog.placeholder_group
@cmds.hybrid_group('configure', with_app_command=False)

View File

@@ -105,6 +105,6 @@ class ModerationData(Registry):
file_data = String()
expiry = Timestamp()
pardoned_by = Integer()
pardoned_at = Integer()
pardoned_at = Timestamp()
pardoned_reason = String()
created_at = Timestamp()

View File

@@ -227,10 +227,10 @@ class Ticket:
name=t(_p('ticket|field:pardoned|name', "Pardoned")),
value=t(_p(
'ticket|field:pardoned|value',
"Pardoned by <&{moderator}> at {timestamp}.\n{reason}"
"Pardoned by <@{moderator}> at {timestamp}.\n{reason}"
)).format(
moderator=data.pardoned_by,
timestamp=discord.utils.format_dt(timestamp),
timestamp=discord.utils.format_dt(data.pardoned_at) if data.pardoned_at else 'Unknown',
reason=data.pardoned_reason or ''
),
inline=False
@@ -297,9 +297,6 @@ class Ticket:
self.expiring.cancel_tasks(self.data.ticketid)
await self.post()
async def _revert(self):
raise NotImplementedError
async def _expire(self):
"""
Actual expiry method.
@@ -321,11 +318,16 @@ class Ticket:
await self.post()
# TODO: Post an extra note to the modlog about the expiry.
async def revert(self):
async def revert(self, reason: Optional[str] = None, **kwargs):
"""
Revert this ticket.
By default this is a no-op.
Ticket types should override to implement any required revert logic.
The optional `reason` paramter is intended for any auditable actions.
"""
raise NotImplementedError
return
async def expire(self):
"""
@@ -336,5 +338,31 @@ class Ticket:
"""
await self._expire()
async def pardon(self):
raise NotImplementedError
async def pardon(self, modid: int, reason: str):
"""
Pardon a ticket.
Specifically, set the state of the ticket to `PARDONED`,
with the given moderator and reason,
and revert the ticket if applicable.
If the ticket is already pardoned, this is a no-op.
"""
if self.data.ticket_state != TicketState.PARDONED:
# Cancel expiry if it was scheduled
self.expiring.cancel_tasks(self.data.ticketid)
# Revert the ticket if it is currently active
if self.data.ticket_state in (TicketState.OPEN, TicketState.EXPIRING):
await self.revert(reason=f"Pardoned by {modid}")
# Set pardoned state
await self.data.update(
ticket_state=TicketState.PARDONED,
pardoned_at=utc_now(),
pardoned_by=modid,
pardoned_reason=reason
)
# Update ticket log message
await self.post()