feat(moderation): Implement ticket interface.

This commit is contained in:
2023-10-19 21:12:47 +03:00
parent 68d4f024a8
commit 8855d2efb8
2 changed files with 757 additions and 1 deletions

View File

@@ -12,7 +12,7 @@ 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, parse_ranges
from utils.lib import utc_now, parse_ranges, parse_time_static
from utils.ui import input
from wards import low_management_ward, high_management_ward, equippable_role, moderator_ward
@@ -23,6 +23,7 @@ from .settings import ModerationSettings
from .settingui import ModerationSettingUI
from .ticket import Ticket
from .tickets import NoteTicket, WarnTicket
from .ticketui import TicketListUI, TicketFilter
_p, _np = babel._p, babel._np
@@ -472,6 +473,105 @@ class ModerationCog(LionCog):
)
await interaction.edit_original_response(embed=embed)
# View tickets
@cmds.hybrid_command(
name=_p('cmd:tickets', "tickets"),
description=_p(
'cmd:tickets|desc',
"View moderation tickets in this server."
)
)
@appcmds.rename(
target_user=_p('cmd:tickets|param:target', "target"),
ticket_type=_p('cmd:tickets|param:type', "type"),
ticket_state=_p('cmd:tickets|param:state', "ticket_state"),
include_pardoned=_p('cmd:tickets|param:pardoned', "include_pardoned"),
acting_moderator=_p('cmd:tickets|param:moderator', "acting_moderator"),
after=_p('cmd:tickets|param:after', "after"),
before=_p('cmd:tickets|param:before', "before"),
)
@appcmds.describe(
target_user=_p(
'cmd:tickets|param:target|desc',
"Filter by tickets acting on a given user."
),
ticket_type=_p(
'cmd:tickets|param:type|desc',
"Filter by ticket type."
),
ticket_state=_p(
'cmd:tickets|param:state|desc',
"Filter by ticket state."
),
include_pardoned=_p(
'cmd:tickets|param:pardoned|desc',
"Whether to only show active tickets, or also include pardoned."
),
acting_moderator=_p(
'cmd:tickets|param:moderator|desc',
"Filter by moderator responsible for the ticket."
),
after=_p(
'cmd:tickets|param:after|desc',
"Only show tickets after this date (YYY-MM-DD HH:MM)"
),
before=_p(
'cmd:tickets|param:before|desc',
"Only show tickets before this date (YYY-MM-DD HH:MM)"
),
)
@appcmds.choices(
ticket_type=[
appcmds.Choice(name=typ.name, value=typ.name)
for typ in (TicketType.NOTE, TicketType.WARNING, TicketType.STUDY_BAN)
],
ticket_state=[
appcmds.Choice(name=state.name, value=state.name)
for state in (
TicketState.OPEN, TicketState.EXPIRING, TicketState.EXPIRED, TicketState.PARDONED,
)
]
)
@appcmds.default_permissions(manage_guild=True)
@appcmds.guild_only
@moderator_ward
async def tickets_cmd(self, ctx: LionContext,
target_user: Optional[discord.User] = None,
ticket_type: Optional[appcmds.Choice[str]] = None,
ticket_state: Optional[appcmds.Choice[str]] = None,
include_pardoned: Optional[bool] = None,
acting_moderator: Optional[discord.User] = None,
after: Optional[str] = None,
before: Optional[str] = None,
):
if not ctx.guild:
return
if not ctx.interaction:
return
filters = TicketFilter(self.bot)
if target_user is not None:
filters.targetids = [target_user.id]
if ticket_type is not None:
filters.types = [TicketType[ticket_type.value]]
if ticket_state is not None:
filters.states = [TicketState[ticket_state.value]]
elif include_pardoned:
filters.states = None
else:
filters.states = [TicketState.OPEN, TicketState.EXPIRING]
if acting_moderator is not None:
filters.moderatorids = [acting_moderator.id]
if after is not None:
filters.after = await parse_time_static(after, ctx.lguild.timezone)
if before is not None:
filters.before = await parse_time_static(before, ctx.lguild.timezone)
ticketsui = TicketListUI(self.bot, ctx.guild, ctx.author.id, filters=filters)
await ticketsui.run(ctx.interaction)
await ticketsui.wait()
# ----- Configuration -----
@LionCog.placeholder_group
@cmds.hybrid_group('configure', with_app_command=False)

View File

@@ -0,0 +1,656 @@
from itertools import chain
from typing import Optional
from dataclasses import dataclass
import asyncio
import datetime as dt
import discord
from discord.ui.select import select, Select, SelectOption, UserSelect
from discord.ui.button import button, Button, ButtonStyle
from discord.ui.text_input import TextInput, TextStyle
from meta import LionBot, conf
from meta.errors import ResponseTimedOut, SafeCancellation, UserInputError
from data import ORDER, Condition
from utils.ui import MessageUI, input
from utils.lib import MessageArgs, tabulate, utc_now
from . import babel, logger
from .ticket import Ticket
from .data import ModerationData, TicketType, TicketState
_p = babel._p
@dataclass
class TicketFilter:
bot: LionBot
after: Optional[dt.datetime] = None
before: Optional[dt.datetime] = None
targetids: Optional[list[int]] = None
moderatorids: Optional[list[int]] = None
types: Optional[list[TicketType]] = None
states: Optional[list[TicketState]] = None
def conditions(self) -> list[Condition]:
conditions = []
Ticket = ModerationData.Ticket
if self.after is not None:
conditions.append(Ticket.created_at >= self.after)
if self.before is not None:
conditions.append(Ticket.created_at < self.before)
if self.targetids is not None:
conditions.append(Ticket.targetid == self.targetids)
if self.moderatorids is not None:
conditions.append(Ticket.moderator_id == self.moderatorids)
if self.types is not None:
conditions.append(Ticket.ticket_type == self.types)
if self.states is not None:
conditions.append(Ticket.ticket_state == self.states)
return conditions
def formatted(self) -> str:
t = self.bot.translator.t
lines = []
if self.after is not None:
name = t(_p(
'ticketfilter|field:after|name',
"Created After"
))
value = discord.utils.format_dt(self.after, 'd')
lines.append((name, value))
if self.before is not None:
name = t(_p(
'ticketfilter|field:before|name',
"Created Before"
))
value = discord.utils.format_dt(self.before, 'd')
lines.append((name, value))
if self.targetids is not None:
name = t(_p(
'ticketfilter|field:targetids|name',
"Targets"
))
value = ', '.join(f"<@{uid}>" for uid in self.targetids) or 'None'
lines.append((name, value))
if self.moderatorids is not None:
name = t(_p(
'ticketfilter|field:moderatorids|name',
"Moderators"
))
value = ', '.join(f"<@{uid}>" for uid in self.moderatorids) or 'None'
lines.append((name, value))
if self.types is not None:
name = t(_p(
'ticketfilter|field:types|name',
"Ticket Types"
))
value = ', '.join(typ.name for typ in self.types) or 'None'
lines.append((name, value))
if self.states is not None:
name = t(_p(
'ticketfilter|field:states|name',
"Ticket States"
))
value = ', '.join(state.name for state in self.states) or 'None'
lines.append((name, value))
if lines:
table = tabulate(*lines)
filterstr = '\n'.join(table)
else:
filterstr = ''
return filterstr
class TicketListUI(MessageUI):
block_len = 10
def _init_children(self):
# HACK to stop ViewWeights complaining that this UI has too many children
# Children will be correctly initialised after parent init.
return []
def __init__(self, bot: LionBot, guild: discord.Guild, callerid: int, filters=None, **kwargs):
super().__init__(callerid=callerid, **kwargs)
self._children = super()._init_children()
self.bot = bot
self.data: ModerationData = bot.db.registries[ModerationData.__name__]
self.guild = guild
self.filters = filters or TicketFilter(bot)
# Paging state
self._pagen = 0
self.blocks = [[]]
# UI State
self.show_filters = False
self.show_tickets = False
self.child_ticket: Optional[TicketUI] = None
@property
def page_count(self):
return len(self.blocks)
@property
def pagen(self):
self._pagen = self._pagen % self.page_count
return self._pagen
@pagen.setter
def pagen(self, value):
self._pagen = value % self.page_count
@property
def current_page(self):
return self.blocks[self.pagen]
# ----- API -----
# ----- UI Components -----
# Edit Filters
@button(
label="EDIT_FILTER_BUTTON_PLACEHOLDER",
style=ButtonStyle.blurple
)
async def edit_filter_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer(thinking=True, ephemeral=True)
self.show_filters = True
self.show_tickets = False
await self.refresh(thinking=press)
async def edit_filter_button_refresh(self):
button = self.edit_filter_button
t = self.bot.translator.t
button.label = t(_p(
'ui:tickets|button:edit_filter|label',
"Edit Filters"
))
button.style = ButtonStyle.grey if not self.show_filters else ButtonStyle.blurple
# Select Ticket
@button(
label="SELECT_TICKET_BUTTON_PLACEHOLDER",
style=ButtonStyle.blurple
)
async def select_ticket_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer(thinking=True, ephemeral=True)
self.show_tickets = True
self.show_filters = False
await self.refresh(thinking=press)
async def select_ticket_button_refresh(self):
button = self.select_ticket_button
t = self.bot.translator.t
button.label = t(_p(
'ui:tickets|button:select_ticket|label',
"Select Ticket"
))
button.style = ButtonStyle.grey if not self.show_tickets else ButtonStyle.blurple
# Pardon All
@button(
label="PARDON_BUTTON_PLACEHOLDER",
style=ButtonStyle.red
)
async def pardon_button(self, press: discord.Interaction, pressed: Button):
t = self.bot.translator.t
tickets = list(chain(*self.blocks))
if not tickets:
raise UserInputError(t(_p(
'ui:tickets|button:pardon|error:no_tickets',
"Not tickets matching the given criterial! Nothing to pardon."
)))
# Request reason via modal
modal_title = t(_p(
'ui:tickets|button:pardon|modal:reason|title',
"Pardon Tickets"
))
input_field = TextInput(
label=t(_p(
'ui:tickets|button: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(
press, modal_title, field=input_field, timeout=300,
)
except asyncio.TimeoutError:
raise ResponseTimedOut
await interaction.response.defer(thinking=True, ephemeral=True)
# Run pardon
for ticket in tickets:
await ticket.pardon(modid=press.user.id, reason=reason)
await self.refresh(thinking=interaction)
async def pardon_button_refresh(self):
button = self.pardon_button
t = self.bot.translator.t
button.label = t(_p(
'ui:tickets|button:pardon|label',
"Pardon All"
))
button.disabled = not bool(self.current_page)
# Filter Ticket Type
@select(
cls=Select,
placeholder="FILTER_TYPE_MENU_PLACEHOLDER",
min_values=1, max_values=3,
)
async def filter_type_menu(self, selection: discord.Interaction, selected: Select):
await selection.response.defer(thinking=True, ephemeral=True)
self.filters.types = [TicketType[value] for value in selected.values] or None
self.pagen = 0
await self.refresh(thinking=selection)
async def filter_type_menu_refresh(self):
menu = self.filter_type_menu
t = self.bot.translator.t
menu.placeholder = t(_p(
'ui:tickets|menu:filter_type|placeholder',
"Select Ticket Types"
))
options = []
descmap = {
TicketType.NOTE: ('Notes',),
TicketType.WARNING: ('Warnings',),
TicketType.STUDY_BAN: ('Video Blacklists',),
}
filtered = self.filters.types
for typ, (name,) in descmap.items():
option = SelectOption(
label=name,
value=typ.name,
default=(filtered is None or typ in filtered)
)
options.append(option)
menu.options = options
# Filter Ticket State
@select(
cls=Select,
placeholder="FILTER_STATE_MENU_PLACEHOLDER",
min_values=1, max_values=4
)
async def filter_state_menu(self, selection: discord.Interaction, selected: Select):
await selection.response.defer(thinking=True, ephemeral=True)
self.filters.states = [TicketState[value] for value in selected.values] or None
self.pagen = 0
await self.refresh(thinking=selection)
async def filter_state_menu_refresh(self):
menu = self.filter_state_menu
t = self.bot.translator.t
menu.placeholder = t(_p(
'ui:tickets|menu:filter_state|placeholder',
"Select Ticket States"
))
options = []
descmap = {
TicketState.OPEN: ('OPEN', ),
TicketState.EXPIRING: ('EXPIRING', ),
TicketState.EXPIRED: ('EXPIRED', ),
TicketState.PARDONED: ('PARDONED', ),
}
filtered = self.filters.states
for state, (name,) in descmap.items():
option = SelectOption(
label=name,
value=state.name,
default=(filtered is None or state in filtered)
)
options.append(option)
menu.options = options
# Filter Ticket Target
@select(
cls=UserSelect,
placeholder="FILTER_TARGET_MENU_PLACEHOLDER",
min_values=0, max_values=10
)
async def filter_target_menu(self, selection: discord.Interaction, selected: UserSelect):
await selection.response.defer(thinking=True, ephemeral=True)
self.filters.targetids = [user.id for user in selected.values] or None
self.pagen = 0
await self.refresh(thinking=selection)
async def filter_target_menu_refresh(self):
menu = self.filter_target_menu
t = self.bot.translator.t
menu.placeholder = t(_p(
'ui:tickets|menu:filter_target|placeholder',
"Select Ticket Targets"
))
# Select Ticket
@select(
cls=Select,
placeholder="TICKETS_MENU_PLACEHOLDER",
min_values=1, max_values=1
)
async def tickets_menu(self, selection: discord.Interaction, selected: Select):
await selection.response.defer(thinking=True, ephemeral=True)
if selected.values:
ticketid = int(selected.values[0])
ticket = await Ticket.fetch_ticket(self.bot, ticketid)
ticketui = TicketUI(self.bot, ticket, self._callerid)
if self.child_ticket:
await self.child_ticket.quit()
self.child_ticket = ticketui
await ticketui.run(selection)
async def tickets_menu_refresh(self):
menu = self.tickets_menu
t = self.bot.translator.t
menu.placeholder = t(_p(
'ui:tickets|menu:tickets|placeholder',
"Select Ticket"
))
options = []
for ticket in self.current_page:
option = SelectOption(
label=f"Ticket #{ticket.data.guild_ticketid}",
value=str(ticket.data.ticketid)
)
options.append(option)
menu.options = options
# Backwards
@button(emoji=conf.emojis.backward, style=ButtonStyle.grey)
async def prev_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer(thinking=True, ephemeral=True)
self.pagen -= 1
await self.refresh(thinking=press)
# Jump to page
@button(label="JUMP_PLACEHOLDER", style=ButtonStyle.blurple)
async def jump_button(self, press: discord.Interaction, pressed: Button):
"""
Jump-to-page button.
Loads a page-switch dialogue.
"""
t = self.bot.translator.t
try:
interaction, value = await input(
press,
title=t(_p(
'ui:tickets|button:jump|input:title',
"Jump to page"
)),
question=t(_p(
'ui:tickets|button:jump|input:question',
"Page number to jump to"
))
)
value = value.strip()
except asyncio.TimeoutError:
return
if not value.lstrip('- ').isdigit():
error_embed = discord.Embed(
title=t(_p(
'ui:tickets|button:jump|error:invalid_page',
"Invalid page number, please try again!"
)),
colour=discord.Colour.brand_red()
)
await interaction.response.send_message(embed=error_embed, ephemeral=True)
else:
await interaction.response.defer(thinking=True)
pagen = int(value.lstrip('- '))
if value.startswith('-'):
pagen = -1 * pagen
elif pagen > 0:
pagen = pagen - 1
self.pagen = pagen
await self.refresh(thinking=interaction)
async def jump_button_refresh(self):
component = self.jump_button
component.label = f"{self.pagen + 1}/{self.page_count}"
component.disabled = (self.page_count <= 1)
# Forward
@button(emoji=conf.emojis.forward, style=ButtonStyle.grey)
async def next_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer(thinking=True)
self.pagen += 1
await self.refresh(thinking=press)
# Quit
@button(emoji=conf.emojis.cancel, style=ButtonStyle.red)
async def quit_button(self, press: discord.Interaction, pressed: Button):
"""
Quit the UI.
"""
await press.response.defer()
if self.child_ticket:
await self.child_ticket.quit()
await self.quit()
# ----- UI Flow -----
def _format_ticket(self, ticket) -> str:
"""
Format a ticket into a single embed line.
"""
components = (
"[#{ticketid}]({link})",
"{created}",
"`{type}[{state}]`",
"<@{targetid}>",
"{content}",
)
formatstr = ' | '.join(components)
data = ticket.data
if not data.content:
content = 'No Content'
elif len(data.content) > 100:
content = data.content[:97] + '...'
else:
content = data.content
ticketstr = formatstr.format(
ticketid=data.guild_ticketid,
link=ticket.jump_url or 'https://lionbot.org',
created=discord.utils.format_dt(data.created_at, 'd'),
type=data.ticket_type.name,
state=data.ticket_state.name,
targetid=data.targetid,
content=content,
)
if data.ticket_state is TicketState.PARDONED:
ticketstr = f"~~{ticketstr}~~"
return ticketstr
async def make_message(self) -> MessageArgs:
t = self.bot.translator.t
embed = discord.Embed(
title=t(_p(
'ui:tickets|embed|title',
"Moderation Tickets in {guild}"
)).format(guild=self.guild.name),
timestamp=utc_now()
)
tickets = self.current_page
if tickets:
desc = '\n'.join(self._format_ticket(ticket) for ticket in tickets)
else:
desc = t(_p(
'ui:tickets|embed|desc:no_tickets',
"No tickets matching the given criteria!"
))
embed.description = desc
filterstr = self.filters.formatted()
if filterstr:
embed.add_field(
name=t(_p(
'ui:tickets|embed|field:filters|name',
"Filters"
)),
value=filterstr,
inline=False
)
return MessageArgs(embed=embed)
async def refresh_layout(self):
to_refresh = (
self.edit_filter_button_refresh(),
self.select_ticket_button_refresh(),
self.pardon_button_refresh(),
self.tickets_menu_refresh(),
self.filter_type_menu_refresh(),
self.filter_state_menu_refresh(),
self.filter_target_menu_refresh(),
self.jump_button_refresh(),
)
await asyncio.gather(*to_refresh)
action_line = (
self.edit_filter_button,
self.select_ticket_button,
self.pardon_button,
)
if self.page_count > 1:
page_line = (
self.prev_button,
self.jump_button,
self.quit_button,
self.next_button,
)
else:
page_line = ()
action_line = (*action_line, self.quit_button)
if self.show_filters:
menus = (
(self.filter_type_menu,),
(self.filter_state_menu,),
(self.filter_target_menu,),
)
elif self.show_tickets and self.current_page:
menus = ((self.tickets_menu,),)
else:
menus = ()
self.set_layout(
action_line,
*menus,
page_line,
)
async def reload(self):
tickets = await Ticket.fetch_tickets(
self.bot,
*self.filters.conditions(),
guildid=self.guild.id,
)
blocks = [
tickets[i:i+self.block_len]
for i in range(0, len(tickets), self.block_len)
]
self.blocks = blocks or [[]]
class TicketUI(MessageUI):
def __init__(self, bot: LionBot, ticket: Ticket, callerid: int, **kwargs):
super().__init__(callerid=callerid, **kwargs)
self.bot = bot
self.ticket = ticket
# ----- API -----
# ----- UI Components -----
# Pardon Ticket
@button(
label="PARDON_BUTTON_PLACEHOLDER",
style=ButtonStyle.red
)
async def pardon_button(self, press: discord.Interaction, pressed: Button):
t = self.bot.translator.t
modal_title = t(_p(
'ui:ticket|button:pardon|modal:reason|title',
"Pardon Moderation Ticket"
))
input_field = TextInput(
label=t(_p(
'ui:ticket|button:pardon|modal:reason|field|label',
"Why are you pardoning this ticket?"
)),
style=TextStyle.long,
min_length=0,
max_length=1024,
)
try:
interaction, reason = await input(
press, modal_title, field=input_field, timeout=300,
)
except asyncio.TimeoutError:
raise ResponseTimedOut
await interaction.response.defer(thinking=True, ephemeral=True)
await self.ticket.pardon(modid=press.user.id, reason=reason)
await self.refresh(thinking=interaction)
async def pardon_button_refresh(self):
button = self.pardon_button
t = self.bot.translator.t
button.label = t(_p(
'ui:ticket|button:pardon|label',
"Pardon"
))
button.disabled = (self.ticket.data.ticket_state is TicketState.PARDONED)
# Quit
@button(emoji=conf.emojis.cancel, style=ButtonStyle.red)
async def quit_button(self, press: discord.Interaction, pressed: Button):
"""
Quit the UI.
"""
await press.response.defer()
await self.quit()
# ----- UI Flow -----
async def make_message(self) -> MessageArgs:
return await self.ticket.make_message()
async def refresh_layout(self):
await self.pardon_button_refresh()
self.set_layout(
(self.pardon_button, self.quit_button,)
)
async def reload(self):
await self.ticket.data.refresh()