diff --git a/bot/modules/moderation/Ticket.py b/bot/modules/moderation/Ticket.py new file mode 100644 index 00000000..325dbaca --- /dev/null +++ b/bot/modules/moderation/Ticket.py @@ -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. diff --git a/bot/modules/moderation/admin.py b/bot/modules/moderation/admin.py new file mode 100644 index 00000000..b7c7b7cc --- /dev/null +++ b/bot/modules/moderation/admin.py @@ -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." diff --git a/bot/modules/moderation/data.py b/bot/modules/moderation/data.py new file mode 100644 index 00000000..bea5386d --- /dev/null +++ b/bot/modules/moderation/data.py @@ -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') diff --git a/bot/modules/moderation/module.py b/bot/modules/moderation/module.py new file mode 100644 index 00000000..bc286ace --- /dev/null +++ b/bot/modules/moderation/module.py @@ -0,0 +1,4 @@ +from cmdClient import Module + + +module = Module("Moderation") diff --git a/bot/modules/moderation/studybans.py b/bot/modules/moderation/studybans.py new file mode 100644 index 00000000..e69de29b diff --git a/bot/modules/moderation/video_watchdog.py b/bot/modules/moderation/video_watchdog.py new file mode 100644 index 00000000..9ff1b145 --- /dev/null +++ b/bot/modules/moderation/video_watchdog.py @@ -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: + # + ...