(Moderation): Base moderation and video system.

Migration to data v2.
Complete core Ticket-based moderation system.
StudyBan ticket implementation.
Video-channel tracking system.
This commit is contained in:
2021-09-25 14:43:28 +03:00
parent 87f3918126
commit 6f48f47ffd
23 changed files with 1530 additions and 303 deletions

View File

@@ -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 *

View File

@@ -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.

View File

@@ -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

View File

@@ -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."

View File

@@ -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',
)

View File

@@ -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 <t:{:.0f}>)".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="<t:{:.0f}>\n(Duration: `{}`)".format(
info.expiry.timestamp(),
strfdelta(datetime.timedelta(seconds=info.duration))
)
)
elif info.ticket_state == TicketState.EXPIRED:
embed.add_field(
name="Expired",
value="<t:{:.0f}>".format(
info.expiry.timestamp(),
)
)
else:
embed.add_field(
name="Expiry",
value="<t:{:.0f}>".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 <t:{:.0f}>.\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()

View File

@@ -0,0 +1,2 @@
from .Ticket import Ticket, TicketType, TicketState
from .studybans import StudyBanTicket

View File

@@ -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
)

View File

@@ -0,0 +1,4 @@
from . import data
from . import admin
from . import watchdog

View File

@@ -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)
)

View File

@@ -0,0 +1,4 @@
from data import Table, RowTable
video_channels = Table('video_channels')

View File

@@ -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 <t:{:.0f}>)".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"
)

View File

@@ -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:
#
...