Merge branch 'moderation' into python-rewrite
This commit is contained in:
@@ -1,2 +1,2 @@
|
|||||||
CONFIG_FILE = "config/bot.conf"
|
CONFIG_FILE = "config/bot.conf"
|
||||||
DATA_VERSION = 1
|
DATA_VERSION = 2
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ def add_pending(pending):
|
|||||||
|
|
||||||
guild_config = RowTable(
|
guild_config = RowTable(
|
||||||
'guild_config',
|
'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',
|
'min_workout_length', 'workout_reward',
|
||||||
'max_tasks', 'task_reward', 'task_reward_limit',
|
'max_tasks', 'task_reward', 'task_reward_limit',
|
||||||
'study_hourly_reward', 'study_hourly_live_bonus',
|
'study_hourly_reward', 'study_hourly_live_bonus',
|
||||||
@@ -73,7 +73,7 @@ lions = RowTable(
|
|||||||
'tracked_time', 'coins',
|
'tracked_time', 'coins',
|
||||||
'workout_count', 'last_workout_start',
|
'workout_count', 'last_workout_start',
|
||||||
'last_study_badgeid',
|
'last_study_badgeid',
|
||||||
'study_ban_count',
|
'video_warned',
|
||||||
),
|
),
|
||||||
('guildid', 'userid'),
|
('guildid', 'userid'),
|
||||||
cache=TTLCache(5000, ttl=60*5),
|
cache=TTLCache(5000, ttl=60*5),
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import logging
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from meta import client, conf
|
from meta import client, conf
|
||||||
|
from settings import GuildSettings, UserSettings
|
||||||
|
|
||||||
from LionModule import LionModule
|
from LionModule import LionModule
|
||||||
|
|
||||||
from .lion import Lion
|
from .lion import Lion
|
||||||
@@ -26,6 +28,18 @@ async def _lion_sync_loop():
|
|||||||
await asyncio.sleep(conf.bot.getint("lion_sync_period"))
|
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
|
@module.launch_task
|
||||||
async def launch_lion_sync_loop(client):
|
async def launch_lion_sync_loop(client):
|
||||||
asyncio.create_task(_lion_sync_loop())
|
asyncio.create_task(_lion_sync_loop())
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ from .workout import *
|
|||||||
from .todo import *
|
from .todo import *
|
||||||
from .reminders import *
|
from .reminders import *
|
||||||
from .renting import *
|
from .renting import *
|
||||||
# from .moderation import *
|
from .moderation import *
|
||||||
from .accountability import *
|
from .accountability import *
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from .module import module
|
from .module import module
|
||||||
|
|
||||||
|
from . import data
|
||||||
from . import admin
|
from . import admin
|
||||||
# from . import video_channels
|
|
||||||
from . import Ticket
|
from . import tickets
|
||||||
|
from . import video
|
||||||
|
|||||||
100
bot/modules/moderation/admin.py
Normal file
100
bot/modules/moderation/admin.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import discord
|
||||||
|
|
||||||
|
from settings import GuildSettings, GuildSetting
|
||||||
|
from wards import guild_admin
|
||||||
|
|
||||||
|
import settings
|
||||||
|
|
||||||
|
from .data import 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_durations'
|
||||||
|
|
||||||
|
_table_interface = studyban_durations
|
||||||
|
_id_column = 'guildid'
|
||||||
|
_data_column = 'duration'
|
||||||
|
_order_column = "rowid"
|
||||||
|
|
||||||
|
_setting = settings.Duration
|
||||||
|
|
||||||
|
write_ward = guild_admin
|
||||||
|
display_name = "studyban_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."
|
||||||
|
|
||||||
|
|
||||||
18
bot/modules/moderation/data.py
Normal file
18
bot/modules/moderation/data.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from data import Table, RowTable
|
||||||
|
|
||||||
|
|
||||||
|
studyban_durations = Table('studyban_durations')
|
||||||
|
|
||||||
|
ticket_info = RowTable(
|
||||||
|
'ticket_info',
|
||||||
|
('ticketid', 'guild_ticketid',
|
||||||
|
'guildid', 'targetid', 'ticket_type', 'ticket_state', 'moderator_id', 'auto',
|
||||||
|
'log_msg_id', 'created_at',
|
||||||
|
'content', 'context', 'addendum', 'duration',
|
||||||
|
'file_name', 'file_data',
|
||||||
|
'expiry',
|
||||||
|
'pardoned_by', 'pardoned_at', 'pardoned_reason'),
|
||||||
|
'ticketid',
|
||||||
|
)
|
||||||
|
|
||||||
|
tickets = Table('tickets')
|
||||||
4
bot/modules/moderation/module.py
Normal file
4
bot/modules/moderation/module.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from cmdClient import Module
|
||||||
|
|
||||||
|
|
||||||
|
module = Module("Moderation")
|
||||||
478
bot/modules/moderation/tickets/Ticket.py
Normal file
478
bot/modules/moderation/tickets/Ticket.py
Normal 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()
|
||||||
2
bot/modules/moderation/tickets/__init__.py
Normal file
2
bot/modules/moderation/tickets/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
from .Ticket import Ticket, TicketType, TicketState
|
||||||
|
from .studybans import StudyBanTicket
|
||||||
126
bot/modules/moderation/tickets/studybans.py
Normal file
126
bot/modules/moderation/tickets/studybans.py
Normal 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
|
||||||
|
)
|
||||||
4
bot/modules/moderation/video/__init__.py
Normal file
4
bot/modules/moderation/video/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from . import data
|
||||||
|
from . import admin
|
||||||
|
|
||||||
|
from . import watchdog
|
||||||
126
bot/modules/moderation/video/admin.py
Normal file
126
bot/modules/moderation/video/admin.py
Normal 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)
|
||||||
|
)
|
||||||
4
bot/modules/moderation/video/data.py
Normal file
4
bot/modules/moderation/video/data.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from data import Table, RowTable
|
||||||
|
|
||||||
|
|
||||||
|
video_channels = Table('video_channels')
|
||||||
374
bot/modules/moderation/video/watchdog.py
Normal file
374
bot/modules/moderation/video/watchdog.py
Normal 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"
|
||||||
|
)
|
||||||
@@ -218,6 +218,18 @@ class Setting:
|
|||||||
Colour=discord.Colour.green()
|
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:
|
class ObjectSettings:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -168,3 +168,30 @@ class donator_roles(settings.RoleList, settings.ListData, settings.Setting):
|
|||||||
return "The donator badges are now:\n{}".format(self.formatted)
|
return "The donator badges are now:\n{}".format(self.formatted)
|
||||||
else:
|
else:
|
||||||
return "The donator badges have been removed."
|
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."
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import datetime
|
||||||
import itertools
|
import itertools
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
@@ -8,6 +9,7 @@ from cmdClient.Context import Context
|
|||||||
from cmdClient.lib import SafeCancellation
|
from cmdClient.lib import SafeCancellation
|
||||||
|
|
||||||
from meta import client
|
from meta import client
|
||||||
|
from utils.lib import parse_dur, strfdur, strfdelta
|
||||||
|
|
||||||
from .base import UserInputError
|
from .base import UserInputError
|
||||||
|
|
||||||
@@ -596,6 +598,73 @@ class IntegerEnum(SettingType):
|
|||||||
return "`{}`".format(value.name)
|
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):
|
class SettingList(SettingType):
|
||||||
"""
|
"""
|
||||||
List of a particular type of setting.
|
List of a particular type of setting.
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ from psycopg2.extensions import QuotedString
|
|||||||
from cmdClient.lib import SafeCancellation
|
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):
|
def prop_tabulate(prop_list, value_list, indent=True):
|
||||||
"""
|
"""
|
||||||
Turns a list of properties and corresponding list of values into
|
Turns a list of properties and corresponding list of values into
|
||||||
@@ -515,9 +523,8 @@ class FieldEnum(str, Enum):
|
|||||||
return QuotedString(self.value)
|
return QuotedString(self.value)
|
||||||
|
|
||||||
|
|
||||||
multiselect_regex = re.compile(
|
def utc_now():
|
||||||
r"^([0-9, -]+)$",
|
"""
|
||||||
re.DOTALL | re.IGNORECASE | re.VERBOSE
|
Return the current timezone-aware utc timestamp.
|
||||||
)
|
"""
|
||||||
tick = '✅'
|
return datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc)
|
||||||
cross = '❌'
|
|
||||||
|
|||||||
147
data/migration/v1-v2/migration.sql
Normal file
147
data/migration/v1-v2/migration.sql
Normal file
@@ -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');
|
||||||
144
data/schema.sql
144
data/schema.sql
@@ -4,7 +4,7 @@ CREATE TABLE VersionHistory(
|
|||||||
time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
author TEXT
|
author TEXT
|
||||||
);
|
);
|
||||||
INSERT INTO VersionHistory (version, author) VALUES (1, 'Initial Creation');
|
INSERT INTO VersionHistory (version, author) VALUES (2, 'Initial Creation');
|
||||||
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION update_timestamp_column()
|
CREATE OR REPLACE FUNCTION update_timestamp_column()
|
||||||
@@ -38,7 +38,8 @@ CREATE TABLE guild_config(
|
|||||||
mod_role BIGINT,
|
mod_role BIGINT,
|
||||||
event_log_channel BIGINT,
|
event_log_channel BIGINT,
|
||||||
mod_log_channel BIGINT,
|
mod_log_channel BIGINT,
|
||||||
study_ban_role BIGINT,
|
alert_channel BIGINT,
|
||||||
|
studyban_role BIGINT,
|
||||||
min_workout_length INTEGER,
|
min_workout_length INTEGER,
|
||||||
workout_reward INTEGER,
|
workout_reward INTEGER,
|
||||||
max_tasks INTEGER,
|
max_tasks INTEGER,
|
||||||
@@ -55,7 +56,9 @@ CREATE TABLE guild_config(
|
|||||||
accountability_lobby BIGINT,
|
accountability_lobby BIGINT,
|
||||||
accountability_bonus INTEGER,
|
accountability_bonus INTEGER,
|
||||||
accountability_reward INTEGER,
|
accountability_reward INTEGER,
|
||||||
accountability_price INTEGER
|
accountability_price INTEGER,
|
||||||
|
video_studyban BOOLEAN,
|
||||||
|
video_grace_period INTEGER
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE unranked_roles(
|
CREATE TABLE unranked_roles(
|
||||||
@@ -217,45 +220,135 @@ CREATE TYPE TicketType AS ENUM (
|
|||||||
'WARNING'
|
'WARNING'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TYPE TicketState AS ENUM (
|
||||||
|
'OPEN',
|
||||||
|
'EXPIRING',
|
||||||
|
'EXPIRED',
|
||||||
|
'PARDONED'
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE tickets(
|
CREATE TABLE tickets(
|
||||||
ticketid SERIAL PRIMARY KEY,
|
ticketid SERIAL PRIMARY KEY,
|
||||||
guildid BIGINT NOT NULL,
|
guildid BIGINT NOT NULL,
|
||||||
targetid BIGINT NOT NULL,
|
targetid BIGINT NOT NULL,
|
||||||
ticket_type TicketType NOT NULL,
|
ticket_type TicketType NOT NULL,
|
||||||
|
ticket_state TicketState NOT NULL DEFAULT 'OPEN',
|
||||||
moderator_id BIGINT NOT NULL,
|
moderator_id BIGINT NOT NULL,
|
||||||
log_msg_id BIGINT,
|
log_msg_id BIGINT,
|
||||||
created_at TIMESTAMP DEFAULT (now() at time zone 'utc'),
|
created_at TIMESTAMP DEFAULT (now() at time zone 'utc'),
|
||||||
content TEXT,
|
auto BOOLEAN DEFAULT FALSE, -- Whether the ticket was automatically created
|
||||||
expiry TIMESTAMP,
|
content TEXT, -- Main ticket content, usually contains the ticket reason
|
||||||
auto BOOLEAN DEFAULT FALSE,
|
context TEXT, -- Optional flexible column only used by some TicketTypes
|
||||||
pardoned BOOLEAN DEFAULT FALSE,
|
addendum TEXT, -- Optional extra text used for after-the-fact context information
|
||||||
pardoned_by BIGINT,
|
duration INTEGER, -- Optional duration column, mostly used by automatic tickets
|
||||||
pardoned_at TIMESTAMP,
|
file_name TEXT, -- Optional file name to accompany the ticket
|
||||||
pardoned_reason TEXT
|
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_members_types ON tickets (guildid, targetid, ticket_type);
|
||||||
|
CREATE INDEX tickets_states ON tickets (ticket_state);
|
||||||
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 VIEW ticket_info AS
|
CREATE VIEW ticket_info AS
|
||||||
SELECT
|
SELECT
|
||||||
*,
|
*,
|
||||||
row_number() OVER (PARTITION BY guildid ORDER BY ticketid) AS guild_ticketid
|
row_number() OVER (PARTITION BY guildid ORDER BY ticketid) AS guild_ticketid
|
||||||
FROM tickets
|
FROM tickets
|
||||||
LEFT JOIN study_bans USING (ticketid)
|
|
||||||
ORDER BY 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 {{{
|
-- Member configuration and stored data {{{
|
||||||
@@ -268,6 +361,7 @@ CREATE TABLE members(
|
|||||||
revision_mute_count INTEGER DEFAULT 0,
|
revision_mute_count INTEGER DEFAULT 0,
|
||||||
last_workout_start TIMESTAMP,
|
last_workout_start TIMESTAMP,
|
||||||
last_study_badgeid INTEGER REFERENCES study_badges ON DELETE SET NULL,
|
last_study_badgeid INTEGER REFERENCES study_badges ON DELETE SET NULL,
|
||||||
|
video_warned BOOLEAN DEFAULT FALSE,
|
||||||
_timestamp TIMESTAMP DEFAULT (now() at time zone 'utc'),
|
_timestamp TIMESTAMP DEFAULT (now() at time zone 'utc'),
|
||||||
PRIMARY KEY(guildid, userid)
|
PRIMARY KEY(guildid, userid)
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user