(Moderation): New module and core system.

Incomplete commit!
Core ticket-based moderation system, with data.
This commit is contained in:
2021-09-23 13:40:17 +03:00
parent 4a0670db65
commit 87f3918126
6 changed files with 381 additions and 0 deletions

View File

@@ -0,0 +1,219 @@
import datetime
import discord
from meta import client
from settings import GuildSettings
from utils.lib import FieldEnum
from . import data
class TicketType(FieldEnum):
"""
The possible ticket types.
"""
NOTE = 'NOTE', 'Note'
WARNING = 'WARNING', 'Warning'
STUDY_BAN = 'STUDY_BAN', 'Study Ban'
MESAGE_CENSOR = 'MESSAGE_CENSOR', 'Message Censor'
INVITE_CENSOR = 'INVITE_CENSOR', 'Invite Censor'
class Ticket:
"""
Abstract base class representing a Ticketed moderation action.
"""
# Type of event the class represents
_ticket_type = None # type: TicketType
_ticket_types = {} # Map: TicketType -> Ticket subclass
def __init__(self, ticketid, *args, **kwargs):
self.ticketid = ticketid
@classmethod
async def create(cls, *args, **kwargs):
"""
Method used to create a new ticket of the current type.
Should add a row to the ticket table, post the ticket, and return the Ticket.
"""
raise NotImplementedError
@property
def data(self):
"""
Ticket row.
This will usually be a row of `ticket_info`.
"""
return data.ticket_info.fetch(self.ticketid)
@property
def guild(self):
return client.get_guild(self.data.guildid)
@property
def member(self):
guild = self.guild
return guild.get_member(self.data.targetid) if guild else None
@property
def msg_args(self):
"""
Ticket message posted in the moderation log.
"""
info = self.data
member = self.member
name = str(member) if member else str(info.targetid)
if info.auto:
title_fmt = "Ticket #{} | {}[Auto] | {}"
else:
title_fmt = "Ticket #{} | {} | {}"
title = title_fmt.format(
info.guild_ticketid,
TicketType[info.ticket_type].desc,
name
)
embed = discord.Embed(
title=title,
description=info.content,
timestamp=datetime.datetime.utcnow()
)
embed.add_field(
name="Target",
value="<@{}>".format(info.targetid)
)
embed.add_field(
name="Moderator",
value="<@{}>".format(info.moderator_id)
)
embed.set_footer(text="ID: {}".format(info.targetid))
return {'embed': embed}
@property
def link(self):
"""
The link to the ticket in the moderation log.
"""
info = self.data
modlog = GuildSettings(info.guildid).mod_log.data
return 'https://discord.com/channels/{}/{}/{}'.format(
info.guildid,
modlog,
info.list_msg_id
)
async def update(self, **kwargs):
"""
Update ticket fields.
"""
fields = (
'targetid', 'moderator_id', 'auto', 'log_msg_id', 'content', 'expiry',
'pardoned', 'pardoned_by', 'pardoned_at', 'pardoned_reason'
)
params = {field: kwargs[field] for field in fields if field in kwargs}
if params:
data.tickets.update_where(params, ticketid=self.ticketid)
async def post(self):
"""
Post or update the ticket in the moderation log.
Also updates the saved message id.
"""
info = self.data
modlog = GuildSettings(info.guildid).mod_log.value
if not modlog:
return
resend = True
if info.log_msg_id:
# Try to fetch the message
message = await modlog.fetch_message(info.log_msg_id)
if message:
if message.author.id == client.user.id:
await message.edit(**self.msg_args)
resend = False
else:
try:
await message.delete()
except discord.HTTPException:
pass
if resend:
message = await modlog.send(**self.msg_args)
self.update(log_msg_id=message.id)
async def _expire(self):
"""
Method to automatically expire a ticket.
"""
raise NotImplementedError
async def pardon(self, moderator, reason, timestamp=None):
"""
Pardon process for the ticket.
"""
raise NotImplementedError
@classmethod
def fetch_where(**kwargs):
"""
Fetchs all tickets matching the given criteria.
"""
...
@classmethod
def fetch_by_id(*args):
"""
Fetch the tickets with the given id(s).
"""
...
@classmethod
def register_ticket_type(cls, ticket_cls):
"""
Decorator to register a new Ticket subclass as a ticket type.
"""
cls._ticket_types[ticket_cls._ticket_type] = ticket_cls
return ticket_cls
@Ticket.register_ticket_type
class StudyBanTicket(Ticket):
_ticket_type = TicketType.STUDY_BAN
@classmethod
async def create(cls, guildid, targetid, moderatorid, reason, duration=None, expiry=None):
"""
Create a new study ban ticket.
"""
# First create the ticket itself
ticket_row = data.tickets.insert(
guildid=guildid,
targetid=targetid,
ticket_type=cls._ticket_type,
moderator_id=moderatorid,
auto=(moderatorid == client.user.id),
content=reason,
expiry=expiry
)
# Then create the study ban
data.study_bans.insert(
ticketid=ticket_row['ticketid'],
study_ban_duration=duration,
)
# Create the Ticket
ticket = cls(ticket_row['ticketid'])
# Post the ticket
await ticket.post()
return ticket
# TODO Auto-expiry system for expiring tickets.

View File

@@ -0,0 +1,132 @@
import discord
from settings import GuildSettings, GuildSetting
from wards import guild_admin
import settings
from .data import video_channels, studyban_durations
@GuildSettings.attach_setting
class mod_log(settings.Channel, GuildSetting):
category = "Moderation"
attr_name = 'mod_log'
_data_column = 'mod_log_channel'
display_name = "mod_log"
desc = "Moderation event logging channel."
long_desc = (
"Channel to post moderation tickets.\n"
"These are produced when a manual or automatic moderation action is performed on a member. "
"This channel acts as a more context rich moderation history source than the audit log."
)
_chan_type = discord.ChannelType.text
@property
def success_response(self):
if self.value:
return "Moderation tickets will be posted to {}.".format(self.formatted)
else:
return "The moderation log has been unset."
@GuildSettings.attach_setting
class studyban_role(settings.Role, GuildSetting):
category = "Moderation"
attr_name = 'studyban_role'
_data_column = 'studyban_role'
display_name = "studyban_role"
desc = "The role given to members to prevent them from using server study features."
long_desc = (
"This role is to be given to members to prevent them from using the server's study features.\n"
"Typically, this role should act as a 'partial mute', and prevent the user from joining study voice channels, "
"or participating in study text channels.\n"
"It will be given automatically after study related offences, "
"such as not enabling video in the video-only channels."
)
@property
def success_response(self):
if self.value:
return "The study ban role is now {}.".format(self.formatted)
@GuildSettings.attach_setting
class studyban_durations(settings.SettingList, settings.ListData, settings.Setting):
category = "Moderation"
attr_name = 'studyban_auto_durations'
_table_interface = studyban_durations
_id_column = 'guildid'
_data_column = 'duration'
_order_column = "rowid"
_setting = settings.Duration
write_ward = guild_admin
display_name = "studyban_auto_durations"
desc = "Sequence of durations for automatic study bans."
long_desc = (
"This sequence describes how long a member will be automatically study-banned for "
"after committing a study-related offence (such as not enabling their video in video only channels).\n"
"If the sequence is `1d, 7d, 30d`, for example, the member will be study-banned "
"for `1d` on their first offence, `7d` on their second offence, and `30d` on their third. "
"On their fourth offence, they will not be unbanned.\n"
"This does not count pardoned offences."
)
accepts = (
"Comma separated list of durations in days/hours/minutes/seconds, for example `12h, 1d, 7d, 30d`."
)
# Flat cache, no need to expire objects
_cache = {}
@property
def success_response(self):
if self.value:
return "The automatic study ban durations are now {}.".format(self.formatted)
else:
return "Automatic study bans will never be reverted."
@GuildSettings.attach_setting
class video_channels(settings.ChannelList, settings.ListData, settings.Setting):
category = "Moderation "
attr_name = 'video_channels'
_table_interface = video_channels
_id_column = 'guildid'
_data_column = 'channelid'
_setting = settings.VoiceChannel
write_ward = guild_admin
display_name = "video_channels"
desc = "Channels where members are required to enable their video."
_force_unique = True
long_desc = (
"Members must keep their video enabled in these channels.\n"
"If they do not keep their video enable (excepting 30 second periods, where they will be warned), "
"they will be kicked from the channel, and, if the `studyban_role` is set, automatically study-banned."
)
# Flat cache, no need to expire objects
_cache = {}
@property
def success_response(self):
if self.value:
return "Membrs must enable their video in the following channels:\n{}".format(self.formatted)
else:
return "There are no video-required channels set up."

View File

@@ -0,0 +1,18 @@
from data import Table, RowTable
video_channels = Table('video_channels')
studyban_durations = Table('studyban_durations')
ticket_info = RowTable(
'ticket_info',
('ticketid', 'guild_ticketid',
'guildid', 'targetid', 'ticket_type', 'moderator_id', 'auto',
'log_msg_id', 'created_at',
'content', 'context', 'duration'
'expiry',
'pardoned', 'pardoned_by', 'pardoned_at', 'pardoned_reason'),
'ticketid',
)
tickets = Table('tickets')

View File

@@ -0,0 +1,4 @@
from cmdClient import Module
module = Module("Moderation")

View File

View File

@@ -0,0 +1,8 @@
"""
Implements a tracker to warn, kick, and study ban members in video channels without video enabled.
"""
async def video_watchdog(client, member, before, after):
# If joining video channel:
#
...