(Moderation): New module and core system.
Incomplete commit! Core ticket-based moderation system, with data.
This commit is contained in:
219
bot/modules/moderation/Ticket.py
Normal file
219
bot/modules/moderation/Ticket.py
Normal 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.
|
||||||
132
bot/modules/moderation/admin.py
Normal file
132
bot/modules/moderation/admin.py
Normal 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."
|
||||||
18
bot/modules/moderation/data.py
Normal file
18
bot/modules/moderation/data.py
Normal 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')
|
||||||
4
bot/modules/moderation/module.py
Normal file
4
bot/modules/moderation/module.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from cmdClient import Module
|
||||||
|
|
||||||
|
|
||||||
|
module = Module("Moderation")
|
||||||
0
bot/modules/moderation/studybans.py
Normal file
0
bot/modules/moderation/studybans.py
Normal file
8
bot/modules/moderation/video_watchdog.py
Normal file
8
bot/modules/moderation/video_watchdog.py
Normal 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:
|
||||||
|
#
|
||||||
|
...
|
||||||
Reference in New Issue
Block a user