From 87f39181262aa8df0d5cb682b0374a66637a6677 Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 23 Sep 2021 13:40:17 +0300 Subject: [PATCH 1/2] (Moderation): New module and core system. Incomplete commit! Core ticket-based moderation system, with data. --- bot/modules/moderation/Ticket.py | 219 +++++++++++++++++++++++ bot/modules/moderation/admin.py | 132 ++++++++++++++ bot/modules/moderation/data.py | 18 ++ bot/modules/moderation/module.py | 4 + bot/modules/moderation/studybans.py | 0 bot/modules/moderation/video_watchdog.py | 8 + 6 files changed, 381 insertions(+) create mode 100644 bot/modules/moderation/Ticket.py create mode 100644 bot/modules/moderation/admin.py create mode 100644 bot/modules/moderation/data.py create mode 100644 bot/modules/moderation/module.py create mode 100644 bot/modules/moderation/studybans.py create mode 100644 bot/modules/moderation/video_watchdog.py 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: + # + ... From 6f48f47ffd948d230bc9614a553fd23ab8844930 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sat, 25 Sep 2021 14:43:28 +0300 Subject: [PATCH 2/2] (Moderation): Base moderation and video system. Migration to data v2. Complete core Ticket-based moderation system. StudyBan ticket implementation. Video-channel tracking system. --- bot/constants.py | 2 +- bot/core/data.py | 4 +- bot/core/module.py | 14 + bot/modules/__init__.py | 2 +- bot/modules/moderation/Ticket.py | 219 --------- bot/modules/moderation/__init__.py | 6 +- bot/modules/moderation/admin.py | 38 +- bot/modules/moderation/data.py | 8 +- bot/modules/moderation/studybans.py | 0 bot/modules/moderation/tickets/Ticket.py | 478 ++++++++++++++++++++ bot/modules/moderation/tickets/__init__.py | 2 + bot/modules/moderation/tickets/studybans.py | 126 ++++++ bot/modules/moderation/video/__init__.py | 4 + bot/modules/moderation/video/admin.py | 126 ++++++ bot/modules/moderation/video/data.py | 4 + bot/modules/moderation/video/watchdog.py | 374 +++++++++++++++ bot/modules/moderation/video_watchdog.py | 8 - bot/settings/base.py | 12 + bot/settings/guild_settings.py | 27 ++ bot/settings/setting_types.py | 69 +++ bot/utils/lib.py | 19 +- data/migration/v1-v2/migration.sql | 147 ++++++ data/schema.sql | 144 +++++- 23 files changed, 1530 insertions(+), 303 deletions(-) delete mode 100644 bot/modules/moderation/Ticket.py delete mode 100644 bot/modules/moderation/studybans.py create mode 100644 bot/modules/moderation/tickets/Ticket.py create mode 100644 bot/modules/moderation/tickets/__init__.py create mode 100644 bot/modules/moderation/tickets/studybans.py create mode 100644 bot/modules/moderation/video/__init__.py create mode 100644 bot/modules/moderation/video/admin.py create mode 100644 bot/modules/moderation/video/data.py create mode 100644 bot/modules/moderation/video/watchdog.py delete mode 100644 bot/modules/moderation/video_watchdog.py create mode 100644 data/migration/v1-v2/migration.sql diff --git a/bot/constants.py b/bot/constants.py index d11c33da..683e6c8f 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -1,2 +1,2 @@ CONFIG_FILE = "config/bot.conf" -DATA_VERSION = 1 +DATA_VERSION = 2 diff --git a/bot/core/data.py b/bot/core/data.py index 1c4b0833..29e54b67 100644 --- a/bot/core/data.py +++ b/bot/core/data.py @@ -53,7 +53,7 @@ def add_pending(pending): guild_config = RowTable( 'guild_config', - ('guildid', 'admin_role', 'mod_role', 'event_log_channel', + ('guildid', 'admin_role', 'mod_role', 'event_log_channel', 'alert_channel', 'min_workout_length', 'workout_reward', 'max_tasks', 'task_reward', 'task_reward_limit', 'study_hourly_reward', 'study_hourly_live_bonus', @@ -73,7 +73,7 @@ lions = RowTable( 'tracked_time', 'coins', 'workout_count', 'last_workout_start', 'last_study_badgeid', - 'study_ban_count', + 'video_warned', ), ('guildid', 'userid'), cache=TTLCache(5000, ttl=60*5), diff --git a/bot/core/module.py b/bot/core/module.py index cd87f41f..daba7dc3 100644 --- a/bot/core/module.py +++ b/bot/core/module.py @@ -2,6 +2,8 @@ import logging import asyncio from meta import client, conf +from settings import GuildSettings, UserSettings + from LionModule import LionModule from .lion import Lion @@ -26,6 +28,18 @@ async def _lion_sync_loop(): await asyncio.sleep(conf.bot.getint("lion_sync_period")) +@module.init_task +def setting_initialisation(client): + """ + Execute all Setting initialisation tasks from GuildSettings and UserSettings. + """ + for setting in GuildSettings.settings.values(): + setting.init_task(client) + + for setting in UserSettings.settings.values(): + setting.init_task(client) + + @module.launch_task async def launch_lion_sync_loop(client): asyncio.create_task(_lion_sync_loop()) diff --git a/bot/modules/__init__.py b/bot/modules/__init__.py index fa0d8bd9..01da8814 100644 --- a/bot/modules/__init__.py +++ b/bot/modules/__init__.py @@ -8,5 +8,5 @@ from .workout import * from .todo import * from .reminders import * from .renting import * -# from .moderation import * +from .moderation import * from .accountability import * diff --git a/bot/modules/moderation/Ticket.py b/bot/modules/moderation/Ticket.py deleted file mode 100644 index 325dbaca..00000000 --- a/bot/modules/moderation/Ticket.py +++ /dev/null @@ -1,219 +0,0 @@ -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/__init__.py b/bot/modules/moderation/__init__.py index 52a384ed..0c0b40d0 100644 --- a/bot/modules/moderation/__init__.py +++ b/bot/modules/moderation/__init__.py @@ -1,5 +1,7 @@ from .module import module +from . import data from . import admin -# from . import video_channels -from . import Ticket + +from . import tickets +from . import video diff --git a/bot/modules/moderation/admin.py b/bot/modules/moderation/admin.py index b7c7b7cc..236aad00 100644 --- a/bot/modules/moderation/admin.py +++ b/bot/modules/moderation/admin.py @@ -5,7 +5,7 @@ from wards import guild_admin import settings -from .data import video_channels, studyban_durations +from .data import studyban_durations @GuildSettings.attach_setting @@ -62,7 +62,7 @@ class studyban_role(settings.Role, GuildSetting): class studyban_durations(settings.SettingList, settings.ListData, settings.Setting): category = "Moderation" - attr_name = 'studyban_auto_durations' + attr_name = 'studyban_durations' _table_interface = studyban_durations _id_column = 'guildid' @@ -72,7 +72,7 @@ class studyban_durations(settings.SettingList, settings.ListData, settings.Setti _setting = settings.Duration write_ward = guild_admin - display_name = "studyban_auto_durations" + display_name = "studyban_durations" desc = "Sequence of durations for automatic study bans." long_desc = ( @@ -98,35 +98,3 @@ class studyban_durations(settings.SettingList, settings.ListData, settings.Setti 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 index bea5386d..65b45790 100644 --- a/bot/modules/moderation/data.py +++ b/bot/modules/moderation/data.py @@ -1,17 +1,17 @@ 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', + 'guildid', 'targetid', 'ticket_type', 'ticket_state', 'moderator_id', 'auto', 'log_msg_id', 'created_at', - 'content', 'context', 'duration' + 'content', 'context', 'addendum', 'duration', + 'file_name', 'file_data', 'expiry', - 'pardoned', 'pardoned_by', 'pardoned_at', 'pardoned_reason'), + 'pardoned_by', 'pardoned_at', 'pardoned_reason'), 'ticketid', ) diff --git a/bot/modules/moderation/studybans.py b/bot/modules/moderation/studybans.py deleted file mode 100644 index e69de29b..00000000 diff --git a/bot/modules/moderation/tickets/Ticket.py b/bot/modules/moderation/tickets/Ticket.py new file mode 100644 index 00000000..1d67f12c --- /dev/null +++ b/bot/modules/moderation/tickets/Ticket.py @@ -0,0 +1,478 @@ +import asyncio +import logging +import traceback +import datetime + +import discord + +from meta import client +from settings import GuildSettings +from utils.lib import FieldEnum, strfdelta, utc_now + +from .. import data +from ..module import module + + +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 TicketState(FieldEnum): + """ + The possible ticket states. + """ + OPEN = 'OPEN', "Active" + EXPIRING = 'EXPIRING', "Active" + EXPIRED = 'EXPIRED', "Expired" + PARDONED = 'PARDONED', "Pardoned" + + +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 + + _expiry_tasks = {} # Map: ticketid -> expiry Task + + 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 target(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. + """ + args = {} + + # Build embed + info = self.data + member = self.target + 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, + TicketState(info.ticket_state).desc, + TicketType(info.ticket_type).desc, + name + ) + + embed = discord.Embed( + title=title, + description=info.content, + timestamp=info.created_at + ) + embed.add_field( + name="Target", + value="<@{}>".format(info.targetid) + ) + + if not info.auto: + embed.add_field( + name="Moderator", + value="<@{}>".format(info.moderator_id) + ) + + # if info.duration: + # value = "`{}` {}".format( + # strfdelta(datetime.timedelta(seconds=info.duration)), + # "(Expiry )".format(info.expiry.timestamp()) if info.expiry else "" + # ) + # embed.add_field( + # name="Duration", + # value=value + # ) + if info.expiry: + if info.ticket_state == TicketState.EXPIRING: + embed.add_field( + name="Expires at", + value="\n(Duration: `{}`)".format( + info.expiry.timestamp(), + strfdelta(datetime.timedelta(seconds=info.duration)) + ) + ) + elif info.ticket_state == TicketState.EXPIRED: + embed.add_field( + name="Expired", + value="".format( + info.expiry.timestamp(), + ) + ) + else: + embed.add_field( + name="Expiry", + value="".format( + info.expiry.timestamp() + ) + ) + + if info.context: + embed.add_field( + name="Context", + value=info.context, + inline=False + ) + + if info.addendum: + embed.add_field( + name="Notes", + value=info.addendum, + inline=False + ) + + if self.state == TicketState.PARDONED: + embed.add_field( + name="Pardoned", + value=( + "Pardoned by <@{}> at .\n{}" + ).format( + info.pardoned_by, + info.pardoned_at.timestamp(), + info.pardoned_reason or "" + ), + inline=False + ) + + embed.set_footer(text="ID: {}".format(info.targetid)) + + args['embed'] = embed + + # Add file + if info.file_name: + args['file'] = discord.File(info.file_data, info.file_name) + + return args + + @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.log_msg_id + ) + + @property + def state(self): + return TicketState(self.data.ticket_state) + + async def update(self, **kwargs): + """ + Update ticket fields. + """ + fields = ( + 'targetid', 'moderator_id', 'auto', 'log_msg_id', + 'content', 'expiry', 'ticket_state', + 'context', 'addendum', 'duration', 'file_name', 'file_data', + 'pardoned_by', 'pardoned_at', 'pardoned_reason', + ) + params = {field: kwargs[field] for field in fields if field in kwargs} + if params: + data.ticket_info.update_where(params, ticketid=self.ticketid) + + await self.update_expiry() + await self.post() + + 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 + try: + 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: + # TODO: Handle file edit + await message.edit(embed=self.msg_args['embed']) + resend = False + else: + try: + await message.delete() + except discord.HTTPException: + pass + + if resend: + message = await modlog.send(**self.msg_args) + self.data.log_msg_id = message.id + except discord.HTTPException: + client.log( + "Cannot post ticket (tid: {}) due to discord exception or issue.".format(self.ticketid) + ) + except Exception: + # This should never happen in normal operation + client.log( + "Error while posting ticket (tid:{})! " + "Exception traceback follows.\n{}".format( + self.ticketid, + traceback.format_exc() + ), + context="TICKETS", + level=logging.ERROR + ) + + @classmethod + def load_expiring(cls): + """ + Load and schedule all expiring tickets. + """ + # TODO: Consider changing this to a flat timestamp system, to avoid storing lots of coroutines. + # TODO: Consider only scheduling the expiries in the next day, and updating this once per day. + # TODO: Only fetch tickets from guilds we are in. + + # Cancel existing expiry tasks + for task in cls._expiry_tasks.values(): + if not task.done(): + task.cancel() + + # Get all expiring tickets + expiring_rows = data.tickets.select_where( + ticket_state=TicketState.EXPIRING + ) + + # Create new expiry tasks + now = utc_now() + cls._expiry_tasks = { + row['ticketid']: asyncio.create_task( + cls._schedule_expiry_for( + row['ticketid'], + (row['expiry'] - now).total_seconds() + ) + ) for row in expiring_rows + } + + # Log + client.log( + "Loaded {} expiring tickets.".format(len(cls._expiry_tasks)), + context="TICKET_LOADER", + ) + + @classmethod + async def _schedule_expiry_for(cls, ticketid, delay): + """ + Schedule expiry for a given ticketid + """ + try: + await asyncio.sleep(delay) + ticket = Ticket.fetch(ticketid) + if ticket: + await asyncio.shield(ticket._expire()) + except asyncio.CancelledError: + return + + def update_expiry(self): + # Cancel any existing expiry task + task = self._expiry_tasks.pop(self.ticketid, None) + if task and not task.done(): + task.cancel() + + # Schedule a new expiry task, if applicable + if self.data.ticket_state == TicketState.EXPIRING: + self._expiry_tasks[self.ticketid] = asyncio.create_task( + self._schedule_expiry_for( + self.ticketid, + (self.data.expiry - utc_now()).total_seconds() + ) + ) + + async def cancel_expiry(self): + """ + Cancel ticket expiry. + + In particular, may be used if another ticket overrides `self`. + Sets the ticket state to `OPEN`, so that it no longer expires. + """ + if self.state == TicketState.EXPIRING: + # Update the ticket state + self.data.ticket_state = TicketState.OPEN + + # Remove from expiry tsks + self.update_expiry() + + # Repost + await self.post() + + async def _revert(self, reason=None): + """ + Method used to revert the ticket action, e.g. unban or remove mute role. + Generally called by `pardon` and `_expire`. + + Must be overriden by the Ticket type, if they implement any revert logic. + """ + raise NotImplementedError + + async def _expire(self): + """ + Method to automatically expire a ticket. + + May be overriden by the Ticket type for more complex expiry logic. + Must set `data.ticket_state` to `EXPIRED` if applicable. + """ + if self.state == TicketState.EXPIRING: + client.log( + "Automatically expiring ticket (tid:{}).".format(self.ticketid), + context="TICKETS" + ) + try: + await self._revert(reason="Automatic Expiry") + except Exception: + # This should never happen in normal operation + client.log( + "Error while expiring ticket (tid:{})! " + "Exception traceback follows.\n{}".format( + self.ticketid, + traceback.format_exc() + ), + context="TICKETS", + level=logging.ERROR + ) + + # Update state + self.data.ticket_state = TicketState.EXPIRED + + # Update log message + await self.post() + + # Post a note to the modlog + modlog = GuildSettings(self.data.guildid).mod_log.value + if modlog: + try: + await modlog.send( + embed=discord.Embed( + colour=discord.Colour.orange(), + description="[Ticket #{}]({}) expired!".format(self.data.guild_ticketid, self.link) + ) + ) + except discord.HTTPException: + pass + + async def pardon(self, moderator, reason, timestamp=None): + """ + Pardon process for the ticket. + + May be overidden by the Ticket type for more complex pardon logic. + Must set `data.ticket_state` to `PARDONED` if applicable. + """ + if self.state != TicketState.PARDONED: + if self.state in (TicketState.OPEN, TicketState.EXPIRING): + try: + await self._revert(reason="Pardoned by {}".format(moderator.id)) + except Exception: + # This should never happen in normal operation + client.log( + "Error while pardoning ticket (tid:{})! " + "Exception traceback follows.\n{}".format( + self.ticketid, + traceback.format_exc() + ), + context="TICKETS", + level=logging.ERROR + ) + + # Update state + with self.data.batch_update(): + self.data.ticket_state = TicketState.PARDONED + self.data.pardoned_at = utc_now() + self.data.pardoned_by = moderator.id + self.data.pardoned_reason = reason + + # Update (i.e. remove) expiry + self.update_expiry() + + # Update log message + await self.post() + + @classmethod + def fetch_tickets(cls, *ticketids, **kwargs): + """ + Fetch tickets matching the given criteria (passed transparently to `select_where`). + Positional arguments are treated as `ticketids`, which are not supported in keyword arguments. + """ + if ticketids: + kwargs['ticketid'] = ticketids + + # Set the ticket type to the class type if not specified + if cls._ticket_type and 'ticket_type' not in kwargs: + kwargs['ticket_type'] = cls._ticket_type + + # This is actually mainly for caching, since we don't pass the data to the initialiser + rows = data.ticket_info.fetch_rows_where( + **kwargs + ) + + return [ + cls._ticket_types[TicketType(row.ticket_type)](row.ticketid) + for row in rows + ] + + @classmethod + def fetch(cls, ticketid): + """ + Return the Ticket with the given id, if found, or `None` otherwise. + """ + tickets = cls.fetch_tickets(ticketid) + return tickets[0] if tickets else None + + @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 + + +@module.launch_task +async def load_expiring_tickets(client): + Ticket.load_expiring() diff --git a/bot/modules/moderation/tickets/__init__.py b/bot/modules/moderation/tickets/__init__.py new file mode 100644 index 00000000..5424a455 --- /dev/null +++ b/bot/modules/moderation/tickets/__init__.py @@ -0,0 +1,2 @@ +from .Ticket import Ticket, TicketType, TicketState +from .studybans import StudyBanTicket diff --git a/bot/modules/moderation/tickets/studybans.py b/bot/modules/moderation/tickets/studybans.py new file mode 100644 index 00000000..cc555743 --- /dev/null +++ b/bot/modules/moderation/tickets/studybans.py @@ -0,0 +1,126 @@ +import datetime +import discord + +from meta import client +from utils.lib import utc_now +from settings import GuildSettings +from data import NOT + +from .. import data +from .Ticket import Ticket, TicketType, TicketState + + +@Ticket.register_ticket_type +class StudyBanTicket(Ticket): + _ticket_type = TicketType.STUDY_BAN + + @classmethod + async def create(cls, guildid, targetid, moderatorid, reason, expiry=None, **kwargs): + """ + 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, + ticket_state=TicketState.EXPIRING if expiry else TicketState.OPEN, + moderator_id=moderatorid, + auto=(moderatorid == client.user.id), + content=reason, + expiry=expiry, + **kwargs + ) + + # Create the Ticket + ticket = cls(ticket_row['ticketid']) + + # Schedule ticket expiry, if applicable + if expiry: + ticket.update_expiry() + + # Cancel any existing studyban expiry for this member + tickets = cls.fetch_tickets( + guildid=guildid, + ticketid=NOT(ticket_row['ticketid']), + targetid=targetid, + ticket_state=TicketState.EXPIRING + ) + for ticket in tickets: + await ticket.cancel_expiry() + + # Post the ticket + await ticket.post() + + # Return the ticket + return ticket + + async def _revert(self, reason=None): + """ + Revert the studyban by removing the role. + """ + guild_settings = GuildSettings(self.data.guildid) + role = guild_settings.studyban_role.value + target = self.target + + if target and role: + try: + await target.remove_roles( + role, + reason="Reverting StudyBan: {}".format(reason) + ) + except discord.HTTPException: + # TODO: Error log? + ... + + @classmethod + async def autoban(cls, guild, target, reason, **kwargs): + """ + Convenience method to automatically studyban a member, for the configured duration. + If the role is set, this will create and return a `StudyBanTicket` regardless of whether the + studyban was successful. + If the role is not set, or the ticket cannot be created, this will return `None`. + """ + # Get the studyban role, fail if there isn't one set, or the role doesn't exist + guild_settings = GuildSettings(guild.id) + role = guild_settings.studyban_role.value + if not role: + return None + + # Attempt to add the role, record failure + try: + await target.add_roles(role, reason="Applying StudyBan: {}".format(reason[:400])) + except discord.HTTPException: + role_failed = True + else: + role_failed = False + + # Calculate the applicable automatic duration and expiry + # First count the existing non-pardoned studybans for this target + studyban_count = data.tickets.select_one_where( + guildid=guild.id, + targetid=target.id, + ticket_type=cls._ticket_type, + ticket_state=NOT(TicketState.PARDONED), + select_columns=('COUNT(*)',) + )[0] + studyban_count = int(studyban_count) + + # Then read the guild setting to find the applicable duration + studyban_durations = guild_settings.studyban_durations.value + if studyban_count < len(studyban_durations): + duration = studyban_durations[studyban_count] + expiry = utc_now() + datetime.timedelta(seconds=duration) + else: + duration = None + expiry = None + + # Create the ticket and return + if role_failed: + kwargs['addendum'] = '\n'.join(( + kwargs.get('addendum', ''), + "Could not add the studyban role! Please add the role manually and check my permissions." + )) + return await cls.create( + guild.id, target.id, client.user.id, reason, duration=duration, expiry=expiry, **kwargs + ) diff --git a/bot/modules/moderation/video/__init__.py b/bot/modules/moderation/video/__init__.py new file mode 100644 index 00000000..e742ba65 --- /dev/null +++ b/bot/modules/moderation/video/__init__.py @@ -0,0 +1,4 @@ +from . import data +from . import admin + +from . import watchdog diff --git a/bot/modules/moderation/video/admin.py b/bot/modules/moderation/video/admin.py new file mode 100644 index 00000000..eed73e86 --- /dev/null +++ b/bot/modules/moderation/video/admin.py @@ -0,0 +1,126 @@ +from collections import defaultdict + +from settings import GuildSettings, GuildSetting +from wards import guild_admin + +import settings + +from .data import video_channels + + +@GuildSettings.attach_setting +class video_channels(settings.ChannelList, settings.ListData, settings.Setting): + category = "Video Channels" + + 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 enabled, they will be asked to enable it in their DMS after `15` seconds, " + "and then kicked from the channel with another warning after the `video_grace_period` duration has passed.\n" + "After the first offence, if the `video_studyban` is enabled and the `studyban_role` is set, " + "they will also be automatically studybanned." + ) + + # 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." + + @classmethod + async def launch_task(cls, client): + """ + Launch initialisation step for the `video_channels` setting. + + Pre-fill cache for the guilds with currently active voice channels. + """ + active_guildids = [ + guild.id + for guild in client.guilds + if any(channel.members for channel in guild.voice_channels) + ] + rows = cls._table_interface.select_where( + guildid=active_guildids + ) + cache = defaultdict(list) + for row in rows: + cache[row['guildid']].append(row['channelid']) + cls._cache.update(cache) + + +@GuildSettings.attach_setting +class video_studyban(settings.Boolean, GuildSetting): + category = "Video Channels" + + attr_name = 'video_studyban' + _data_column = 'video_studyban' + + display_name = "video_studyban" + desc = "Whether to studyban members if they don't enable their video." + + long_desc = ( + "If enabled, members who do not enable their video in the configured `video_channels` will be " + "study-banned after a single warning.\n" + "When disabled, members will only be warned and removed from the channel." + ) + + _default = True + _outputs = {True: "Enabled", False: "Disabled"} + + @property + def success_response(self): + if self.value: + "Members will now be study banned if they don't enable their video in the configured video channels." + else: + "Members will not be studybanned if they don't enable their video in video channels." + + +@GuildSettings.attach_setting +class video_grace_period(settings.Duration, GuildSetting): + category = "Video Channels" + + attr_name = 'video_grace_period' + _data_column = 'video_grace_period' + + display_name = "video_grace_period" + desc = "How long to wait before kicking/studybanning members who don't enable their video." + + long_desc = ( + "The period after a member has been asked to enable their video in a video-only channel " + "before they will be kicked from the channel, and warned or studybanned (if enabled)." + ) + + _default = 45 + + @classmethod + def _format_data(cls, id: int, data, **kwargs): + """ + Return the string version of the data. + """ + if data is None: + return None + else: + return "`{} seconds`".format(data) + + @property + def success_response(self): + return ( + "Members who do not enable their video will " + "be disconnected after {}.".format(self.formatted) + ) diff --git a/bot/modules/moderation/video/data.py b/bot/modules/moderation/video/data.py new file mode 100644 index 00000000..27e9fee1 --- /dev/null +++ b/bot/modules/moderation/video/data.py @@ -0,0 +1,4 @@ +from data import Table, RowTable + + +video_channels = Table('video_channels') diff --git a/bot/modules/moderation/video/watchdog.py b/bot/modules/moderation/video/watchdog.py new file mode 100644 index 00000000..09d2b6c7 --- /dev/null +++ b/bot/modules/moderation/video/watchdog.py @@ -0,0 +1,374 @@ +""" +Implements a tracker to warn, kick, and studyban members in video channels without video enabled. +""" +import asyncio +import logging +import datetime +import discord + +from meta import client +from core import Lion +from utils.lib import strfdelta +from settings import GuildSettings + +from ..tickets import StudyBanTicket +from ..module import module + + +_tasks = {} # (guildid, userid) -> Task + + +async def _send_alert(member, embed, alert_channel): + """ + Sends an embed to the member. + If we can't reach the member, send it via alert_channel, if it exists. + Returns the message, if it was sent, otherwise None. + """ + try: + return await member.send(embed=embed) + except discord.Forbidden: + if alert_channel: + try: + return await alert_channel.send( + content=( + "{} (Please enable your DMs with me to get alerts privately!)" + ).format(member.mention), + embed=embed + ) + except discord.HTTPException: + pass + + +async def _join_video_channel(member, channel): + # Sanity checks + if not member.voice and member.voice.channel: + # Not in a voice channel + return + if member.voice.self_video: + # Already have video on + return + + # First wait for 15 seconds for them to turn their video on + try: + await asyncio.sleep(15) + except asyncio.CancelledError: + # They left the channel or turned their video on + return + + # Fetch the relevant settings and build embeds + guild_settings = GuildSettings(member.guild.id) + grace_period = guild_settings.video_grace_period.value + studyban = guild_settings.video_studyban.value + studyban_role = guild_settings.studyban_role.value + alert_channel = guild_settings.alert_channel.value + + lion = Lion.fetch(member.guild.id, member.id) + previously_warned = lion.data.video_warned + + request_embed = discord.Embed( + title="Please enable your video!", + description=( + "**You have joined the video-only channel {}!**\n" + "Please **enable your video** or **leave the channel** in the next `{}` seconds, " + "otherwise you will be **disconnected** and " + "potentially **banned** from using this server's study facilities." + ).format( + channel.mention, + grace_period + ), + colour=discord.Colour.orange(), + timestamp=datetime.datetime.utcnow() + ).set_footer( + text=member.guild.name, + icon_url=member.guild.icon_url + ) + + thanks_embed = discord.Embed( + title="Thanks for enabling your video! Best of luck with your study.", + colour=discord.Colour.green(), + timestamp=datetime.datetime.utcnow() + ).set_footer( + text=member.guild.name, + icon_url=member.guild.icon_url + ) + + bye_embed = discord.Embed( + title="Thanks for leaving the channel promptly!", + colour=discord.Colour.green(), + timestamp=datetime.datetime.utcnow() + ).set_footer( + text=member.guild.name, + icon_url=member.guild.icon_url + ) + + # Send the notification message and wait for the grace period + out_msg = None + alert_task = asyncio.create_task(_send_alert( + member, + request_embed, + alert_channel + )) + try: + out_msg = await asyncio.shield(alert_task) + await asyncio.sleep(grace_period) + except asyncio.CancelledError: + # They left the channel or turned their video on + + # Finish the message task if it wasn't complete + if not alert_task.done(): + out_msg = await alert_task + + # Update the notification message + # The out_msg may be None here, if we have no way of reaching the member + if out_msg is not None: + try: + if not member.voice or not (member.voice.channel == channel): + await out_msg.edit(embed=bye_embed) + elif member.voice.self_video: + await out_msg.edit(embed=thanks_embed) + except discord.HTTPException: + pass + return + + # Disconnect, notify, warn, and potentially study ban + # Don't allow this to be cancelled any more + _tasks.pop((member.guild.id, member.id), None) + + # First disconnect + client.log( + ("Disconnecting member {} (uid: {}) in guild {} (gid: {}) from video channel {} (cid:{}) " + "for not enabling their video.").format( + member.name, + member.id, + member.guild.name, + member.guild.id, + channel.name, + channel.id + ), + context="VIDEO_WATCHDOG" + ) + try: + await member.edit( + voice_channel=None, + reason="Member in video-only channel did not enable video." + ) + except discord.HTTPException: + # TODO: Add it to the moderation ticket + # Error log? + ... + + # Then warn or study ban, with appropriate notification + only_warn = not previously_warned or not studyban or not studyban_role + + if only_warn: + # Give them an official warning + embed = discord.Embed( + title="You have received a warning!", + description=( + "You must enable your camera in camera-only rooms." + ), + colour=discord.Colour.red(), + timestamp=datetime.datetime.utcnow() + ) + embed.add_field( + name="Info", + value=( + "*Warnings appear in your moderation history. " + "Failure to comply, or repeated warnings, " + "may result in muting, studybanning, or server banning.*" + ) + ) + embed.set_footer( + icon_url=member.guild.icon_url, + text=member.guild.name + ) + await _send_alert(member, embed, alert_channel) + # TODO: Warning ticket and related embed. + lion.data.video_warned = True + else: + # Apply an automatic studyban + ticket = await StudyBanTicket.autoban( + member.guild, + member, + "Failed to enable their video in time in the video channel {}.".format(channel.mention) + ) + if ticket: + tip = "TIP: When joining a video only study room, always be ready to enable your video immediately!" + embed = discord.Embed( + title="You have been studybanned!", + description=( + "You have been banned from studying in **{}**.\n" + "Study features, including **study voice channels** and **study text channels**, " + "will ***not be available to you until this ban is lifted.***".format( + member.guild.name, + ) + ), + colour=discord.Colour.red(), + timestamp=datetime.datetime.utcnow() + ) + embed.add_field( + name="Reason", + value="Failure to enable your video in time in a video-only channel.\n\n*{}*".format(tip) + ) + if ticket.data.duration: + embed.add_field( + name="Duration", + value="`{}` (Expires )".format( + strfdelta(datetime.timedelta(seconds=ticket.data.duration)), + ticket.data.expiry.timestamp() + ), + inline=False + ) + embed.set_footer( + text=member.guild.name, + icon_url=member.guild.icon_url + ) + await _send_alert(member, embed, alert_channel) + else: + # This should be impossible + # TODO: Cautionary error logging + pass + + +@client.add_after_event("voice_state_update") +async def video_watchdog(client, member, before, after): + if member.bot: + return + + task_key = (member.guild.id, member.id) + + if after.channel != before.channel: + # Channel change, cancel any running tasks for the member + task = _tasks.pop(task_key, None) + if task and not task.done(): + task.cancel() + + # Check whether they are joining a video channel, run join logic if so + if after.channel and not after.self_video: + video_channel_ids = GuildSettings(member.guild.id).video_channels.data + if after.channel.id in video_channel_ids: + client.log( + ("Launching join task for member {} (uid: {}) " + "in guild {} (gid: {}) and video channel {} (cid:{}).").format( + member.name, + member.id, + member.guild.name, + member.guild.id, + after.channel.name, + after.channel.id + ), + context="VIDEO_WATCHDOG", + level=logging.DEBUG + ) + _tasks[task_key] = asyncio.create_task(_join_video_channel(member, after.channel)) + else: + video_channel_ids = GuildSettings(member.guild.id).video_channels.data + if after.channel and after.channel.id in video_channel_ids: + channel = after.channel + if after.self_video: + # If they have their video on, cancel any running tasks + task = _tasks.pop(task_key, None) + if task and not task.done(): + task.cancel() + else: + # They have their video off + # Don't do anything if there are running tasks, the tasks will handle it + task = _tasks.get(task_key, None) + if task and not task.done(): + return + + # Otherwise, give them 10 seconds + _tasks[task_key] = task = asyncio.create_task(asyncio.sleep(10)) + try: + await task + except asyncio.CancelledError: + # Task was cancelled, they left the channel or turned their video on + return + + # Then kick them out, alert them, and event log it + client.log( + ("Disconnecting member {} (uid: {}) in guild {} (gid: {}) from video channel {} (cid:{}) " + "for disabling their video.").format( + member.name, + member.id, + member.guild.name, + member.guild.id, + channel.name, + channel.id + ), + context="VIDEO_WATCHDOG" + ) + try: + await member.edit( + voice_channel=None, + reason="Removing non-video member from video-only channel." + ) + await _send_alert( + member, + discord.Embed( + title="You have been kicked from the video channel.", + description=( + "You were disconnected from the video-only channel {} for disabling your video.\n" + "Please keep your video on at all times, and leave the channel if you need " + "to make adjustments!" + ).format( + channel.mention, + ), + colour=discord.Colour.red(), + timestamp=datetime.datetime.utcnow() + ).set_footer( + text=member.guild.name, + icon_url=member.guild.icon_url + ), + GuildSettings(member.guild.id).alert_channel.value + ) + except discord.Forbidden: + GuildSettings(member.guild.id).event_log.log( + "I attempted to disconnect {} from the video-only channel {} " + "because they disabled their video, but I didn't have the required permissions!\n".format( + member.mention, + channel.mention + ) + ) + else: + GuildSettings(member.guild.id).event_log.log( + "{} was disconnected from the video-only channel {} " + "because they disabled their video.".format( + member.mention, + channel.mention + ) + ) + + +@module.launch_task +async def load_video_channels(client): + """ + Process existing video channel members. + Pre-fills the video channel cache by running the setting launch task. + + Treats members without video on as having just joined. + """ + # Run the video channel initialisation to populate the setting cache + await GuildSettings.settings.video_channels.launch_task(client) + + # Launch join tasks for all members in video channels without video enabled + video_channels = ( + channel + for guild in client.guilds + for channel in guild.voice_channels + if channel.members and channel.id in GuildSettings.settings.video_channels.get(guild.id).data + ) + to_task = [ + (member, channel) + for channel in video_channels + for member in channel.members + if not member.voice.self_video + ] + for member, channel in to_task: + _tasks[(member.guild.id, member.id)] = asyncio.create_task(_join_video_channel(member, channel)) + + if to_task: + client.log( + "Launched {} join tasks for members who need to enable their video.".format(len(to_task)), + context="VIDEO_CHANNEL_LAUNCH" + ) diff --git a/bot/modules/moderation/video_watchdog.py b/bot/modules/moderation/video_watchdog.py deleted file mode 100644 index 9ff1b145..00000000 --- a/bot/modules/moderation/video_watchdog.py +++ /dev/null @@ -1,8 +0,0 @@ -""" -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: - # - ... diff --git a/bot/settings/base.py b/bot/settings/base.py index 3c19709b..2793ee16 100644 --- a/bot/settings/base.py +++ b/bot/settings/base.py @@ -218,6 +218,18 @@ class Setting: Colour=discord.Colour.green() )) + @classmethod + def init_task(self, client): + """ + Initialisation task to be excuted during client initialisation. + May be used for e.g. populating a cache or required client setup. + + Main application must execute the initialisation task before the setting is used. + Further, the task must always be executable, if the setting is loaded. + Conditional initalisation should go in the relevant module's init tasks. + """ + return None + class ObjectSettings: """ diff --git a/bot/settings/guild_settings.py b/bot/settings/guild_settings.py index 62436d27..9e20dea0 100644 --- a/bot/settings/guild_settings.py +++ b/bot/settings/guild_settings.py @@ -168,3 +168,30 @@ class donator_roles(settings.RoleList, settings.ListData, settings.Setting): return "The donator badges are now:\n{}".format(self.formatted) else: return "The donator badges have been removed." + + +@GuildSettings.attach_setting +class alert_channel(settings.Channel, GuildSetting): + category = "Meta" + + attr_name = 'alert_channel' + _data_column = 'alert_channel' + + display_name = "alert_channel" + desc = "Channel to display global user alerts." + + long_desc = ( + "This channel will be used for group notifications, " + "for example group timers and anti-cheat messages, " + "as well as for critical alerts to users that have their direct messages disapbled.\n" + "It should be visible to all members." + ) + + _chan_type = discord.ChannelType.text + + @property + def success_response(self): + if self.value: + return "The alert channel is now {}.".format(self.formatted) + else: + return "The alert channel has been unset." diff --git a/bot/settings/setting_types.py b/bot/settings/setting_types.py index a1060c02..35464e2f 100644 --- a/bot/settings/setting_types.py +++ b/bot/settings/setting_types.py @@ -1,3 +1,4 @@ +import datetime import itertools from enum import IntEnum from typing import Any, Optional @@ -8,6 +9,7 @@ from cmdClient.Context import Context from cmdClient.lib import SafeCancellation from meta import client +from utils.lib import parse_dur, strfdur, strfdelta from .base import UserInputError @@ -596,6 +598,73 @@ class IntegerEnum(SettingType): return "`{}`".format(value.name) +class Duration(SettingType): + """ + Duration type, stores a time duration in seconds. + + Types: + data: Optional[int] + The stored number of seconds. + value: Optional[int] + The stored number of seconds. + """ + accepts = "A number of days, hours, minutes, and seconds, e.g. `2d 4h 10s`." + + # Set an upper limit on the duration + _max = 60 * 60 * 24 * 365 + _min = None + + # Whether to allow empty durations + # This is particularly useful since the duration parser will return 0 for most non-duration strings + allow_zero = False + + @classmethod + def _data_from_value(cls, id: int, value: Optional[bool], **kwargs): + """ + Both data and value are of type Optional[int]. + Directly return the provided value as data. + """ + return value + + @classmethod + def _data_to_value(cls, id: int, data: Optional[bool], **kwargs): + """ + Both data and value are of type Optional[int]. + Directly return the internal data as the value. + """ + return data + + @classmethod + async def _parse_userstr(cls, ctx: Context, id: int, userstr: str, **kwargs): + """ + Parse the provided duration. + """ + if userstr.lower() == "none": + return None + + num = parse_dur(userstr) + + if num == 0 and not cls.allow_zero: + raise UserInputError("The provided duration cannot be `0`!") + + if cls._max is not None and num > cls._max: + raise UserInputError("Duration cannot be longer than `{}`!".format(strfdur(cls._max))) + if cls._min is not None and num < cls._min: + raise UserInputError("Duration connot be shorter than `{}`!".format(strfdur(cls._min))) + + return num + + @classmethod + def _format_data(cls, id: int, data: Optional[int], **kwargs): + """ + Return the string version of the data. + """ + if data is None: + return None + else: + return "`{}`".format(strfdelta(datetime.timedelta(seconds=data))) + + class SettingList(SettingType): """ List of a particular type of setting. diff --git a/bot/utils/lib.py b/bot/utils/lib.py index 3c113f4f..b11a368e 100644 --- a/bot/utils/lib.py +++ b/bot/utils/lib.py @@ -9,6 +9,14 @@ from psycopg2.extensions import QuotedString from cmdClient.lib import SafeCancellation +multiselect_regex = re.compile( + r"^([0-9, -]+)$", + re.DOTALL | re.IGNORECASE | re.VERBOSE +) +tick = '✅' +cross = '❌' + + def prop_tabulate(prop_list, value_list, indent=True): """ Turns a list of properties and corresponding list of values into @@ -515,9 +523,8 @@ class FieldEnum(str, Enum): return QuotedString(self.value) -multiselect_regex = re.compile( - r"^([0-9, -]+)$", - re.DOTALL | re.IGNORECASE | re.VERBOSE -) -tick = '✅' -cross = '❌' +def utc_now(): + """ + Return the current timezone-aware utc timestamp. + """ + return datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc) diff --git a/data/migration/v1-v2/migration.sql b/data/migration/v1-v2/migration.sql new file mode 100644 index 00000000..967bd01a --- /dev/null +++ b/data/migration/v1-v2/migration.sql @@ -0,0 +1,147 @@ +DROP TABLE IF EXISTS study_bans CASCADE; +DROP TABLE IF EXISTS tickets CASCADE; +DROP TABLE IF EXISTS study_ban_auto_durations CASCADE; + +ALTER TABLE members ADD COLUMN + video_warned BOOLEAN DEFAULT FALSE; + +ALTER TABLE guild_config DROP COLUMN study_ban_role; + +ALTER TABLE guild_config ADD COLUMN + alert_channel BIGINT, + video_studyban BOOLEAN, + video_grace_period INTEGER + studyban_role BIGINT; + + +CREATE TYPE TicketState AS ENUM ( + 'OPEN', + 'EXPIRING', + 'EXPIRED', + 'PARDONED' +); + +CREATE TABLE tickets( + ticketid SERIAL PRIMARY KEY, + guildid BIGINT NOT NULL, + targetid BIGINT NOT NULL, + ticket_type TicketType NOT NULL, + ticket_state TicketState NOT NULL DEFAULT 'OPEN', + moderator_id BIGINT NOT NULL, + log_msg_id BIGINT, + created_at TIMESTAMP DEFAULT (now() at time zone 'utc'), + auto BOOLEAN DEFAULT FALSE, -- Whether the ticket was automatically created + content TEXT, -- Main ticket content, usually contains the ticket reason + context TEXT, -- Optional flexible column only used by some TicketTypes + addendum TEXT, -- Optional extra text used for after-the-fact context information + duration INTEGER, -- Optional duration column, mostly used by automatic tickets + file_name TEXT, -- Optional file name to accompany the ticket + file_data BYTEA, -- Optional file data to accompany the ticket + expiry TIMESTAMPTZ, -- Time to automatically expire the ticket + pardoned_by BIGINT, -- Actorid who pardoned the ticket + pardoned_at TIMESTAMPTZ, -- Time when the ticket was pardoned + pardoned_reason TEXT -- Reason the ticket was pardoned +); +CREATE INDEX tickets_members_types ON tickets (guildid, targetid, ticket_type); +CREATE INDEX tickets_states ON tickets (ticket_state); + +CREATE VIEW ticket_info AS + SELECT + *, + row_number() OVER (PARTITION BY guildid ORDER BY ticketid) AS guild_ticketid + FROM tickets + ORDER BY ticketid; + +ALTER TABLE ticket_info ALTER ticket_state SET DEFAULT 'OPEN'; +ALTER TABLE ticket_info ALTER created_at SET DEFAULT (now() at time zone 'utc'); +ALTER TABLE ticket_info ALTER auto SET DEFAULT False; + +CREATE OR REPLACE FUNCTION instead_of_ticket_info() + RETURNS trigger AS +$$ +BEGIN + IF TG_OP = 'INSERT' THEN + INSERT INTO tickets( + guildid, + targetid, + ticket_type, + ticket_state, + moderator_id, + log_msg_id, + created_at, + auto, + content, + context, + addendum, + duration, + file_name, + file_data, + expiry, + pardoned_by, + pardoned_at, + pardoned_reason + ) VALUES ( + NEW.guildid, + NEW.targetid, + NEW.ticket_type, + NEW.ticket_state, + NEW.moderator_id, + NEW.log_msg_id, + NEW.created_at, + NEW.auto, + NEW.content, + NEW.context, + NEW.addendum, + NEW.duration, + NEW.file_name, + NEW.file_data, + NEW.expiry, + NEW.pardoned_by, + NEW.pardoned_at, + NEW.pardoned_reason + ) RETURNING ticketid INTO NEW.ticketid; + RETURN NEW; + ELSIF TG_OP = 'UPDATE' THEN + UPDATE tickets SET + guildid = NEW.guildid, + targetid = NEW.targetid, + ticket_type = NEW.ticket_type, + ticket_state = NEW.ticket_state, + moderator_id = NEW.moderator_id, + log_msg_id = NEW.log_msg_id, + created_at = NEW.created_at, + auto = NEW.auto, + content = NEW.content, + context = NEW.context, + addendum = NEW.addendum, + duration = NEW.duration, + file_name = NEW.file_name, + file_data = NEW.file_data, + expiry = NEW.expiry, + pardoned_by = NEW.pardoned_by, + pardoned_at = NEW.pardoned_at, + pardoned_reason = NEW.pardoned_reason + WHERE + ticketid = OLD.ticketid; + RETURN NEW; + ELSIF TG_OP = 'DELETE' THEN + DELETE FROM tickets WHERE ticketid = OLD.ticketid; + RETURN OLD; + END IF; +END; +$$ LANGUAGE PLPGSQL; + +CREATE TRIGGER instead_of_ticket_info_trig + INSTEAD OF INSERT OR UPDATE OR DELETE ON + ticket_info FOR EACH ROW + EXECUTE PROCEDURE instead_of_ticket_info(); + +CREATE TABLE studyban_durations( + rowid SERIAL PRIMARY KEY, + guildid BIGINT NOT NULL, + duration INTEGER NOT NULL +); +CREATE INDEX studyban_durations_guilds ON studyban_durations(guildid); + + +INSERT INTO VersionHistory (version, author) VALUES (2, 'v1-v2 Migration'); diff --git a/data/schema.sql b/data/schema.sql index 4c44f0ad..d46b533b 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -4,7 +4,7 @@ CREATE TABLE VersionHistory( time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, author TEXT ); -INSERT INTO VersionHistory (version, author) VALUES (0, 'Initial Creation'); +INSERT INTO VersionHistory (version, author) VALUES (2, 'Initial Creation'); CREATE OR REPLACE FUNCTION update_timestamp_column() @@ -38,7 +38,8 @@ CREATE TABLE guild_config( mod_role BIGINT, event_log_channel BIGINT, mod_log_channel BIGINT, - study_ban_role BIGINT, + alert_channel BIGINT, + studyban_role BIGINT, min_workout_length INTEGER, workout_reward INTEGER, max_tasks INTEGER, @@ -55,7 +56,9 @@ CREATE TABLE guild_config( accountability_lobby BIGINT, accountability_bonus INTEGER, accountability_reward INTEGER, - accountability_price INTEGER + accountability_price INTEGER, + video_studyban BOOLEAN, + video_grace_period INTEGER ); CREATE TABLE unranked_roles( @@ -217,45 +220,135 @@ CREATE TYPE TicketType AS ENUM ( 'WARNING' ); +CREATE TYPE TicketState AS ENUM ( + 'OPEN', + 'EXPIRING', + 'EXPIRED', + 'PARDONED' +); + CREATE TABLE tickets( ticketid SERIAL PRIMARY KEY, guildid BIGINT NOT NULL, targetid BIGINT NOT NULL, ticket_type TicketType NOT NULL, + ticket_state TicketState NOT NULL DEFAULT 'OPEN', moderator_id BIGINT NOT NULL, log_msg_id BIGINT, created_at TIMESTAMP DEFAULT (now() at time zone 'utc'), - content TEXT, - expiry TIMESTAMP, - auto BOOLEAN DEFAULT FALSE, - pardoned BOOLEAN DEFAULT FALSE, - pardoned_by BIGINT, - pardoned_at TIMESTAMP, - pardoned_reason TEXT + auto BOOLEAN DEFAULT FALSE, -- Whether the ticket was automatically created + content TEXT, -- Main ticket content, usually contains the ticket reason + context TEXT, -- Optional flexible column only used by some TicketTypes + addendum TEXT, -- Optional extra text used for after-the-fact context information + duration INTEGER, -- Optional duration column, mostly used by automatic tickets + file_name TEXT, -- Optional file name to accompany the ticket + file_data BYTEA, -- Optional file data to accompany the ticket + expiry TIMESTAMPTZ, -- Time to automatically expire the ticket + pardoned_by BIGINT, -- Actorid who pardoned the ticket + pardoned_at TIMESTAMPTZ, -- Time when the ticket was pardoned + pardoned_reason TEXT -- Reason the ticket was pardoned ); CREATE INDEX tickets_members_types ON tickets (guildid, targetid, ticket_type); - -CREATE TABLE study_bans( - ticketid INTEGER REFERENCES tickets(ticketid), - study_ban_duration INTEGER -); -CREATE INDEX study_ban_tickets ON study_bans (ticketid); - -CREATE TABLE study_ban_auto_durations( - rowid SERIAL PRIMARY KEY, - guildid BIGINT NOT NULL, - duration INTEGER NOT NULL -); -CREATE INDEX study_ban_auto_durations_guilds ON study_ban_auto_durations (guildid); - +CREATE INDEX tickets_states ON tickets (ticket_state); CREATE VIEW ticket_info AS SELECT *, row_number() OVER (PARTITION BY guildid ORDER BY ticketid) AS guild_ticketid FROM tickets - LEFT JOIN study_bans USING (ticketid) ORDER BY ticketid; + +ALTER TABLE ticket_info ALTER ticket_state SET DEFAULT 'OPEN'; +ALTER TABLE ticket_info ALTER created_at SET DEFAULT (now() at time zone 'utc'); +ALTER TABLE ticket_info ALTER auto SET DEFAULT False; + +CREATE OR REPLACE FUNCTION instead_of_ticket_info() + RETURNS trigger AS +$$ +BEGIN + IF TG_OP = 'INSERT' THEN + INSERT INTO tickets( + guildid, + targetid, + ticket_type, + ticket_state, + moderator_id, + log_msg_id, + created_at, + auto, + content, + context, + addendum, + duration, + file_name, + file_data, + expiry, + pardoned_by, + pardoned_at, + pardoned_reason + ) VALUES ( + NEW.guildid, + NEW.targetid, + NEW.ticket_type, + NEW.ticket_state, + NEW.moderator_id, + NEW.log_msg_id, + NEW.created_at, + NEW.auto, + NEW.content, + NEW.context, + NEW.addendum, + NEW.duration, + NEW.file_name, + NEW.file_data, + NEW.expiry, + NEW.pardoned_by, + NEW.pardoned_at, + NEW.pardoned_reason + ) RETURNING ticketid INTO NEW.ticketid; + RETURN NEW; + ELSIF TG_OP = 'UPDATE' THEN + UPDATE tickets SET + guildid = NEW.guildid, + targetid = NEW.targetid, + ticket_type = NEW.ticket_type, + ticket_state = NEW.ticket_state, + moderator_id = NEW.moderator_id, + log_msg_id = NEW.log_msg_id, + created_at = NEW.created_at, + auto = NEW.auto, + content = NEW.content, + context = NEW.context, + addendum = NEW.addendum, + duration = NEW.duration, + file_name = NEW.file_name, + file_data = NEW.file_data, + expiry = NEW.expiry, + pardoned_by = NEW.pardoned_by, + pardoned_at = NEW.pardoned_at, + pardoned_reason = NEW.pardoned_reason + WHERE + ticketid = OLD.ticketid; + RETURN NEW; + ELSIF TG_OP = 'DELETE' THEN + DELETE FROM tickets WHERE ticketid = OLD.ticketid; + RETURN OLD; + END IF; +END; +$$ LANGUAGE PLPGSQL; + +CREATE TRIGGER instead_of_ticket_info_trig + INSTEAD OF INSERT OR UPDATE OR DELETE ON + ticket_info FOR EACH ROW + EXECUTE PROCEDURE instead_of_ticket_info(); + + +CREATE TABLE studyban_durations( + rowid SERIAL PRIMARY KEY, + guildid BIGINT NOT NULL, + duration INTEGER NOT NULL +); +CREATE INDEX studyban_durations_guilds ON studyban_durations (guildid); -- }}} -- Member configuration and stored data {{{ @@ -268,6 +361,7 @@ CREATE TABLE members( revision_mute_count INTEGER DEFAULT 0, last_workout_start TIMESTAMP, last_study_badgeid INTEGER REFERENCES study_badges ON DELETE SET NULL, + video_warned BOOLEAN DEFAULT FALSE, _timestamp TIMESTAMP DEFAULT (now() at time zone 'utc'), PRIMARY KEY(guildid, userid) );