rewrite: New Video channels and moderation.

This commit is contained in:
2023-08-15 14:03:23 +03:00
parent 7e6217a2ae
commit 2cc90375c7
21 changed files with 2227 additions and 11 deletions

View File

@@ -1018,6 +1018,19 @@ UPDATE guild_config SET greeting_message = NULL, returning_message = NULL WHERE
UPDATE guild_config SET greeting_channel = NULL WHERE greeting_channel = 1;
-- }}}
-- Moderation {{{
UPDATE guild_config SET studyban_role = NULL WHERE video_studyban = False;
CREATE TABLE video_exempt_roles(
guildid BIGINT NOT NULL,
roleid BIGINT NOT NULL,
_timestamp TIMESTAMPTZ NOT NULL DEFAULT now(),
FOREIGN KEY (guildid) REFERENCES guild_config (guildid) ON DELETE CASCADE ON UPDATE CASCADE,
PRIMARY KEY (guildid, roleid)
);
-- }}}
INSERT INTO VersionHistory (version, author) VALUES (13, 'v12-v13 migration');
COMMIT;

View File

@@ -0,0 +1,10 @@
DROP TABLE IF EXISTS video_exempt_roles CASCADE;
UPDATE guild_config SET studyban_role = NULL WHERE video_studyban = False;
CREATE TABLE video_exempt_roles(
guildid BIGINT NOT NULL,
roleid BIGINT NOT NULL,
_timestamp TIMESTAMPTZ NOT NULL DEFAULT now(),
FOREIGN KEY (guildid) REFERENCES guild_config (guildid) ON DELETE CASCADE ON UPDATE CASCADE,
PRIMARY KEY (guildid, roleid)
);

View File

@@ -83,10 +83,10 @@ def colour_escape(fmt: str) -> str:
return fmt
log_format = ('[%(green)%(asctime)-19s%(reset)][%(red)%(levelname)-8s%(reset)]' +
'[%(cyan)%(app)-15s%(reset)]' +
'[%(cyan)%(context)-24s%(reset)]' +
'[%(cyan)%(actionstr)-22s%(reset)]' +
log_format = ('%(green)%(asctime)-19s%(reset)|%(red)%(levelname)-8s%(reset)|' +
'%(cyan)%(app)-15s%(reset)|' +
'%(cyan)%(context)-24s%(reset)|' +
'%(cyan)%(actionstr)-22s%(reset)|' +
' %(bold)%(cyan)%(name)s:%(reset)' +
' %(white)%(message)s%(ctxstr)s%(reset)')
log_format = colour_escape(log_format)

View File

@@ -15,6 +15,8 @@ active = [
'.rooms',
'.rolemenus',
'.member_admin',
'.moderation',
'.video_channels',
'.meta',
'.test',
]

View File

@@ -19,6 +19,8 @@ from babel.settingui import LocaleDashboard
from modules.schedule.ui.settingui import ScheduleDashboard
from modules.statistics.settings import StatisticsDashboard
from modules.member_admin.settingui import MemberAdminDashboard
from modules.moderation.settingui import ModerationDashboard
from modules.video_channels.settingui import VideoDashboard
from . import babel, logger
@@ -33,6 +35,7 @@ class GuildDashboard(BasePager):
"""
pages = [
(MemberAdminDashboard, LocaleDashboard, EconomyDashboard,),
(ModerationDashboard, VideoDashboard,),
(VoiceTrackerDashboard, TextTrackerDashboard, RankDashboard, StatisticsDashboard,),
(TasklistDashboard, RoomDashboard, TimerDashboard,),
(ScheduleDashboard,),
@@ -138,17 +141,14 @@ class GuildDashboard(BasePager):
menu = self.config_menu
menu.placeholder = t(_p(
'ui:dashboard|menu:config|placeholder',
"Expand Configuration Group"
"Open Configuration Panel"
))
options = []
for i, page in enumerate(self.pages):
for j, section in enumerate(page):
option = SelectOption(
label=t(section.section_name).format(
bot=self.bot,
commands=self.bot.core.mention_cache
),
label=section(self.bot, self.guildid).option_name,
value=str(i * 10 + j)
)
options.append(option)

View File

@@ -47,6 +47,7 @@ class MemberAdminUI(ConfigUI):
setting = self.get_instance(Settings.GreetingChannel)
setting.value = selected.values[0] if selected.values else None
await setting.write()
await selection.delete_original_response()
async def greetch_menu_refresh(self):
menu = self.greetch_menu
@@ -89,6 +90,7 @@ class MemberAdminUI(ConfigUI):
@select(
cls=RoleSelect,
placeholder="BOT_AUTOROLES_MENU_PLACEHOLDER",
min_values=0, max_values=25
)
async def bot_autoroles_menu(self, selection: discord.Interaction, selected: RoleSelect):
"""
@@ -254,5 +256,9 @@ class MemberAdminDashboard(DashboardSection):
"dash:member_admin|title",
"Greetings and Initial Roles ({commands[configure welcome]})"
)
_option_name = _p(
"dash:member_admin|dropdown|placeholder",
"Greetings and Initial Roles Panel"
)
configui = MemberAdminUI
setting_classes = MemberAdminUI.setting_classes

View File

@@ -0,0 +1,10 @@
import logging
from babel.translator import LocalBabel
logger = logging.getLogger(__name__)
babel = LocalBabel('moderation')
async def setup(bot):
from .cog import ModerationCog
await bot.add_cog(ModerationCog(bot))

View File

@@ -0,0 +1,208 @@
from typing import Optional
from collections import defaultdict
import asyncio
import discord
from discord.ext import commands as cmds
from discord import app_commands as appcmds
from meta import LionCog, LionBot, LionContext
from meta.logger import log_wrap
from meta.sharding import THIS_SHARD
from core.data import CoreData
from utils.lib import utc_now
from wards import low_management_ward, high_management_ward, equippable_role
from . import babel, logger
from .data import ModerationData, TicketType, TicketState
from .settings import ModerationSettings
from .settingui import ModerationSettingUI
from .ticket import Ticket
_p = babel._p
class ModerationCog(LionCog):
def __init__(self, bot: LionBot):
self.bot = bot
self.data = bot.db.load_registry(ModerationData())
self.settings = ModerationSettings()
# TODO: Needs refactor
self.expiring_tickets = Ticket.expiring
self.expiring_tickets.executor = self._expiring_callback
async def cog_load(self):
await self.data.init()
model_settings = (
self.settings.TicketLog,
self.settings.ModRole,
self.settings.AlertChannel,
)
for model_setting in model_settings:
self.bot.core.guild_config.register_model_setting(model_setting)
configcog = self.bot.get_cog('ConfigCog')
if configcog is None:
logger.warning(
"Could not load ConfigCog. "
"Moderation configuration will not crossload."
)
else:
self.crossload_group(self.configure_group, configcog.configure_group)
if self.bot.is_ready():
await self.initialise()
async def cog_unload(self):
if self.expiring_tickets._monitor_task:
self.expiring_tickets._monitor_task.cancel()
@LionCog.listener('on_ready')
@log_wrap(action="Load Expiring Tickets")
async def initialise(self):
# Load expiring
expiring = await Ticket.fetch_tickets(
self.bot,
THIS_SHARD,
ticket_state=TicketState.EXPIRING,
)
tasks = [
(ticket.data.ticketid, ticket.data.expiry.timestamp())
for ticket in expiring if ticket.data.expiry
]
logger.info(
f"Scheduled {len(tasks)} expiring tickets."
)
self.expiring_tickets.schedule_tasks(*tasks)
self.expiring_tickets.start()
async def _expiring_callback(self, ticketid: int):
ticket = await Ticket.fetch_ticket(self.bot, ticketid)
if ticket.data.ticket_state is not TicketState.EXPIRING:
return
now = utc_now()
if ticket.data.expiry > now:
logger.info(
f"Rescheduling expiry for ticket '{ticketid}' "
f"which expires later {ticket.data.expiry}"
)
self.expiring_tickets.schedule_task(ticketid, ticket.data.expiry.timestamp())
else:
logger.info(
f"Running expiry task for ticket '{ticketid}'"
)
await ticket.expire()
# ----- API -----
async def send_alert(self, member: discord.Member, **kwargs) -> Optional[discord.Message]:
"""
Send a moderation alert to the specified member.
Sends the alert directly to the member if possible,
otherwise to the configured `alert_channel`.
Takes into account the member notification preferences (TODO)
"""
try:
return await member.send(**kwargs)
except discord.HTTPException:
alert_channel = await self.settings.AlertChannel.get(member.guild.id)
if alert_channel:
try:
return await alert_channel.send(content=member.mention, **kwargs)
except discord.HTTPException:
pass
async def get_ticket_webhook(self, guild: discord.Guild):
"""
Get the ticket log webhook data, if it exists.
If it does not exist, but the ticket channel is set, tries to create it.
"""
...
# ----- Commands -----
# ----- Configuration -----
@LionCog.placeholder_group
@cmds.hybrid_group('configure', with_app_command=False)
async def configure_group(self, ctx: LionContext):
...
@configure_group.command(
name=_p('cmd:configure_moderation', "moderation"),
description=_p(
'cmd:configure_moderation|desc',
"Configure general moderation settings."
)
)
@appcmds.rename(
modrole=ModerationSettings.ModRole._display_name,
ticket_log=ModerationSettings.TicketLog._display_name,
alert_channel=ModerationSettings.AlertChannel._display_name,
)
@appcmds.describe(
modrole=ModerationSettings.ModRole._desc,
ticket_log=ModerationSettings.TicketLog._desc,
alert_channel=ModerationSettings.AlertChannel._desc,
)
@high_management_ward
async def configure_moderation(self, ctx: LionContext,
modrole: Optional[discord.Role] = None,
ticket_log: Optional[discord.TextChannel] = None,
alert_channel: Optional[discord.TextChannel] = None,
):
if not ctx.guild:
return
if not ctx.interaction:
return
await ctx.interaction.response.defer(thinking=True)
modified = []
if modrole is not None:
setting = self.settings.ModRole
await setting._check_value(ctx.guild.id, modrole)
instance = setting(ctx.guild.id, modrole.id)
modified.append(instance)
if ticket_log is not None:
setting = self.settings.TicketLog
await setting._check_value(ctx.guild.id, ticket_log)
instance = setting(ctx.guild.id, ticket_log.id)
modified.append(instance)
if alert_channel is not None:
setting = self.settings.AlertChannel
await setting._check_value(ctx.guild.id, alert_channel)
instance = setting(ctx.guild.id, alert_channel.id)
modified.append(instance)
if modified:
ack_lines = []
update_args = {}
# All settings are guild model settings so we can simultaneously write
for instance in modified:
update_args[instance._column] = instance.data
ack_lines.append(instance.update_message)
# Do the ack
tick = self.bot.config.emojis.tick
embed = discord.Embed(
colour=discord.Colour.brand_green(),
description='\n'.join(f"{tick} {line}" for line in ack_lines)
)
await ctx.reply(embed=embed)
# Dispatch updates to any listeners
for instance in modified:
instance.dispatch_update()
if ctx.channel.id not in ModerationSettingUI._listening or not modified:
ui = ModerationSettingUI(self.bot, ctx.guild.id, ctx.channel.id)
await ui.run(ctx.interaction)
await ui.wait()

View File

@@ -0,0 +1,110 @@
from enum import Enum
from data import Registry, Table, RowModel, RegisterEnum
from data.columns import (
Column,
Integer, String, Bool, Timestamp,
)
class TicketType(Enum):
"""
Schema
------
CREATE TYPE TicketType AS ENUM (
'NOTE',
'STUDY_BAN',
'MESSAGE_CENSOR',
'INVITE_CENSOR',
'WARNING'
);
"""
NOTE = 'NOTE',
STUDY_BAN = 'STUDY_BAN',
MESSAGE_CENSOR = 'MESSAGE_CENSOR',
INVITE_CENSOR = 'INVITE_CENSOR',
WARNING = 'WARNING',
class TicketState(Enum):
"""
Schema
------
CREATE TYPE TicketState AS ENUM (
'OPEN',
'EXPIRING',
'EXPIRED',
'PARDONED'
);
"""
OPEN = 'OPEN',
EXPIRING = 'EXPIRING',
EXPIRED = 'EXPIRED',
PARDONED = 'PARDONED',
class ModerationData(Registry):
_TicketType = RegisterEnum(TicketType, 'TicketType')
_TicketState = RegisterEnum(TicketState, 'TicketState')
class Ticket(RowModel):
"""
Schema
------
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;
"""
_tablename_ = 'ticket_info'
ticketid = Integer(primary=True)
guild_ticketid = Integer()
guildid = Integer()
targetid = Integer()
ticket_type: Column[TicketType] = Column()
ticket_state: Column[TicketState] = Column()
moderator_id = Integer()
log_msg_id = Integer()
auto = Bool()
content = String()
context = String()
addendum = String()
duration = Integer()
file_name = String()
file_data = String()
expiry = Timestamp()
pardoned_by = Integer()
pardoned_at = Integer()
pardoned_reason = String()
created_at = Timestamp()

View File

@@ -0,0 +1,168 @@
from settings import ModelData
from settings.groups import SettingGroup
from settings.setting_types import (
ChannelSetting, RoleSetting,
)
from core.data import CoreData
from babel.translator import ctx_translator
from . import babel
_p = babel._p
class ModerationSettings(SettingGroup):
class TicketLog(ModelData, ChannelSetting):
setting_id = "ticket_log"
_event = 'guildset_ticket_log'
_display_name = _p('guildset:ticket_log', "ticket_log")
_desc = _p(
'guildset:ticket_log|desc',
"Private moderation log to send tickets and moderation events."
)
_long_desc = _p(
'guildset:ticket_log|long_desc',
"Warnings, notes, video blacklists, and other moderation events "
"will be posted as numbered tickets with context to this log."
)
_accepts = _p(
'guildset:ticket_log|accepts',
"Ticket channel name or id."
)
_default = None
_model = CoreData.Guild
_column = CoreData.Guild.mod_log_channel.name
@property
def update_message(self) -> str:
t = ctx_translator.get().t
value = self.value
if value:
resp = t(_p(
'guildset:ticket_log|set_response:set',
"Moderation tickets will be sent to {channel}"
)).format(channel=value.mention)
else:
resp = t(_p(
'guildset:ticket_log|set_response:unset',
"Moderation tickets will not be logged to a channel."
))
return resp
@classmethod
def _format_data(cls, parent_id, data, **kwargs):
t = ctx_translator.get().t
if data is not None:
return super()._format_data(parent_id, data, **kwargs)
else:
return t(_p(
'guildset:ticket_log|formatted:unset',
"Not Set."
))
class AlertChannel(ModelData, ChannelSetting):
setting_id = "alert_channel"
_event = 'guildset_alert_channel'
_display_name = _p('guildset:alert_channel', "alert_channel")
_desc = _p(
'guildset:alert_channel|desc',
"Moderation notification channel for members with DMs disabled."
)
_long_desc = _p(
'guildset:alert_channel|long_desc',
"When I need to send a member a moderation-related notification "
"(e.g. asking them to enable their video in a video channel) "
"from this server, I will try to send it via direct messages. "
"If this fails, I will instead mention the user in this channel."
)
_accepts = _p(
'guildset:alert_channel|accepts',
"Alert channel name or id."
)
_default = None
_model = CoreData.Guild
_column = CoreData.Guild.alert_channel.name
@property
def update_message(self) -> str:
t = ctx_translator.get().t
value = self.value
if value:
resp = t(_p(
'guildset:alert_channel|set_response:set',
"Moderation alerts will be sent to {channel}"
)).format(channel=value.mention)
else:
resp = t(_p(
'guildset:alert_channel|set_response:unset',
"Moderation alerts will be ignored if the member cannot be reached."
))
return resp
@classmethod
def _format_data(cls, parent_id, data, **kwargs):
t = ctx_translator.get().t
if data is not None:
return super()._format_data(parent_id, data, **kwargs)
else:
return t(_p(
'guildset:alert_channel|formatted:unset',
"Not Set (Only alert via direct message.)"
))
class ModRole(ModelData, RoleSetting):
setting_id = "mod_role"
_event = 'guildset_mod_role'
_display_name = _p('guildset:mod_role', "mod_role")
_desc = _p(
'guildset:mod_role|desc',
"Guild role permitted to view configuration and perform moderation tasks."
)
_long_desc = _p(
'guildset:mod_role|long_desc',
"Members with the set role will be able to access my configuration panels, "
"and perform some moderation tasks, such us setting up pomodoro timers. "
"Moderators cannot reconfigure most bot configuration, "
"or perform operations they do not already have permission for in Discord."
)
_accepts = _p(
'guildset:mod_role|accepts',
"Moderation role name or id."
)
_default = None
_model = CoreData.Guild
_column = CoreData.Guild.mod_role.name
@property
def update_message(self) -> str:
t = ctx_translator.get().t
value = self.value
if value:
resp = t(_p(
'guildset:mod_role|set_response:set',
"Members with the {role} will be considered moderators."
)).format(role=value.mention)
else:
resp = t(_p(
'guildset:mod_role|set_response:unset',
"No members will be given moderation privileges."
))
return resp
@classmethod
def _format_data(cls, parent_id, data, **kwargs):
t = ctx_translator.get().t
if data is not None:
return super()._format_data(parent_id, data, **kwargs)
else:
return t(_p(
'guildset:mod_role|formatted:unset',
"Not Set."
))

View File

@@ -0,0 +1,157 @@
import asyncio
import discord
from discord.ui.button import button, Button, ButtonStyle
from discord.ui.select import select, ChannelSelect, RoleSelect
from meta import LionBot
from utils.ui import ConfigUI, DashboardSection
from utils.lib import MessageArgs
from . import babel
from .settings import ModerationSettings
_p = babel._p
class ModerationSettingUI(ConfigUI):
setting_classes = (
ModerationSettings.TicketLog,
ModerationSettings.AlertChannel,
ModerationSettings.ModRole,
)
def __init__(self, bot: LionBot, guildid: int, channelid, **kwargs):
self.settings = bot.get_cog('ModerationCog').settings
super().__init__(bot, guildid, channelid, **kwargs)
# ----- UI Components -----
# Ticket Log selector
@select(
cls=ChannelSelect,
placeholder="TICKET_LOG_MENU_PLACEHOLDER",
min_values=0, max_values=1
)
async def ticket_log_menu(self, selection: discord.Interaction, selected: ChannelSelect):
"""
Single channel selector for the `ticket_log` setting.
"""
await selection.response.defer(thinking=True, ephemeral=True)
setting = self.get_instance(ModerationSettings.TicketLog)
setting.value = selected.values[0] if selected.values else None
await setting.write()
await selection.delete_original_response()
async def ticket_log_menu_refresh(self):
menu = self.ticket_log_menu
t = self.bot.translator.t
menu.placeholder = t(_p(
'ui:moderation_config|menu:ticket_log|placeholder',
"Select Ticket Log"
))
# Alert Channel selector
@select(
cls=ChannelSelect,
placeholder="ALERT_CHANNEL_MENU_PLACEHOLDER",
min_values=0, max_values=1
)
async def alert_channel_menu(self, selection: discord.Interaction, selected: ChannelSelect):
"""
Single channel selector for the `alert_channel` setting.
"""
await selection.response.defer(thinking=True, ephemeral=True)
setting = self.get_instance(ModerationSettings.AlertChannel)
setting.value = selected.values[0] if selected.values else None
await setting.write()
await selection.delete_original_response()
async def alert_channel_menu_refresh(self):
menu = self.alert_channel_menu
t = self.bot.translator.t
menu.placeholder = t(_p(
'ui:moderation_config|menu:alert_channel|placeholder',
"Select Alert Channel"
))
# Moderation Role Selector
@select(
cls=RoleSelect,
placeholder="MODROLE_MENU_PLACEHOLDER",
min_values=0, max_values=1
)
async def modrole_menu(self, selection: discord.Interaction, selected: RoleSelect):
"""
Single role selector for the `moderation_role` setting.
"""
await selection.response.defer(thinking=True, ephemeral=True)
setting = self.get_instance(ModerationSettings.ModRole)
setting.value = selected.values[0] if selected.values else None
await setting.write()
await selection.delete_original_response()
async def modrole_menu_refresh(self):
menu = self.modrole_menu
t = self.bot.translator.t
menu.placeholder = t(_p(
'ui:moderation_config|menu:modrole|placeholder',
"Select Moderator Role"
))
# ----- UI Flow -----
async def make_message(self) -> MessageArgs:
t = self.bot.translator.t
title = t(_p(
'ui:moderation_config|embed|title',
"Moderation Configuration Panel"
))
embed = discord.Embed(
title=title,
colour=discord.Colour.orange(),
)
for setting in self.instances:
embed.add_field(**setting.embed_field, inline=False)
return MessageArgs(embed=embed)
async def reload(self):
self.instances = [
await setting.get(self.guildid)
for setting in self.setting_classes
]
async def refresh_components(self):
component_refresh = (
self.edit_button_refresh(),
self.close_button_refresh(),
self.reset_button_refresh(),
self.ticket_log_menu_refresh(),
self.alert_channel_menu_refresh(),
self.modrole_menu_refresh(),
)
await asyncio.gather(*component_refresh)
self.set_layout(
(self.ticket_log_menu,),
(self.alert_channel_menu,),
(self.modrole_menu,),
(self.edit_button, self.reset_button, self.close_button,)
)
class ModerationDashboard(DashboardSection):
section_name = _p(
"dash:moderation|title",
"Moderation Settings ({commands[configure moderation]})"
)
_option_name = _p(
"dash:moderation|dropdown|placeholder",
"Moderation Panel"
)
configui = ModerationSettingUI
setting_classes = ModerationSettingUI.setting_classes

View File

@@ -0,0 +1,339 @@
import asyncio
import datetime as dt
from typing import Optional
import discord
from core.lion_guild import LionGuild
from meta import LionBot
from utils.lib import MessageArgs, jumpto, strfdelta, utc_now
from utils.monitor import TaskMonitor
from . import babel, logger
from .data import ModerationData, TicketState, TicketType
from .settings import ModerationSettings
_p = babel._p
# Factory map, TicketType -> Ticket subclass
_ticket_types = {}
def ticket_factory(ticket_type: TicketType):
"""
Register a Ticket subclass as the factory for the given ticket_type.
"""
def decorator(cls):
_ticket_types[ticket_type] = cls
return cls
return decorator
class Ticket:
"""
ABC representing a single recorded moderation action.
All subclasses must be constructable from the same args.
"""
__slots__ = ('lguild', 'bot', 'data')
# Task manager keeping track of expiring ticket tasks
# Tickets are keyed by ticketid
expiring = TaskMonitor()
def __init__(self, lguild: LionGuild, ticket_data: ModerationData.Ticket, **kwargs):
self.lguild = lguild
self.bot: LionBot = lguild.bot
self.data = ticket_data
@classmethod
async def create(cls, *args, **kwargs):
"""
Create a new ticket of this type.
Must be extended by concrete ticket types.
`kwargs` should generally be passed directly to the data constructor.
This method may perform discord actions such as adding or removing a role.
If the actions fail, the method may passthrough the resulting HTTPException.
"""
raise NotImplementedError
@classmethod
async def fetch_ticket(cls, bot: LionBot, ticketid: int) -> 'Ticket':
"""
Fetch a single requested ticketid.
Factory method which uses the internal `_ticket_types` map
to instantiate the correct Ticket subclass.
"""
registry: ModerationData = bot.db.registries['ModerationData']
data = await registry.Ticket.fetch(ticketid)
if data:
lguild = await bot.core.lions.fetch_guild(data.guildid)
cls = _ticket_types.get(data.ticket_type, cls)
ticket = cls(lguild, data)
else:
ticket = None
return ticket
@classmethod
async def fetch_tickets(cls, bot: LionBot, *args, **kwargs) -> list['Ticket']:
"""
Fetch tickets matching the given criteria.
Factory method which uses the internal `_ticket_types` to
instantiate the correct classes.
"""
registry: ModerationData = bot.db.registries['ModerationData']
rows = await registry.Ticket.fetch_where(*args, **kwargs)
tickets = []
if rows:
guildids = set(row.guildid for row in rows)
lguilds = await bot.core.lions.fetch_guilds(*guildids)
for row in rows:
lguild = lguilds[row.guildid]
cls = _ticket_types.get(row.ticket_type, cls)
ticket = cls(lguild, row)
tickets.append(ticket)
return tickets
@property
def guild(self):
return self.bot.get_guild(self.data.guildid)
@property
def target(self):
guild = self.guild
if guild:
return guild.get_member(self.data.targetid)
else:
return None
@property
def type(self):
return self.data.ticket_type
@property
def jump_url(self) -> Optional[str]:
"""
A link to jump to the ticket message in the ticket log,
if it has been posted.
May not be valid if the ticket was not posted or the ticket log has changed.
"""
ticket_log_id = self.lguild.config.get(ModerationSettings.TicketLog.setting_id).data
if ticket_log_id and self.data.log_msg_id:
return jumpto(self.data.guildid, ticket_log_id, self.data.log_msg_id)
else:
return None
async def make_message(self) -> MessageArgs:
"""
Base form of the ticket message posted to the moderation ticket log.
Subclasses are expected to extend or override this,
but this forms the default and standard structure for a ticket.
"""
t = self.bot.translator.t
# TODO: Better solution for guild ticket ids
await self.data.refresh()
data = self.data
member = self.target
name = str(member) if member else str(data.targetid)
if data.auto:
title_fmt = t(_p(
'ticket|title:auto',
"Ticket #{ticketid} | {state} | {type}[Auto] | {name}"
))
else:
title_fmt = t(_p(
'ticket|title:manual',
"Ticket #{ticketid} | {state} | {type} | {name}"
))
title = title_fmt.format(
ticketid=data.guild_ticketid,
state=data.ticket_state.name,
type=data.ticket_type.name,
name=name
)
embed = discord.Embed(
title=title,
description=data.content,
timestamp=data.created_at,
colour=discord.Colour.orange()
)
embed.add_field(
name=t(_p('ticket|field:target|name', "Target")),
value=f"<@{data.targetid}>"
)
if not data.auto:
embed.add_field(
name=t(_p('ticket|field:moderator|name', "Moderator")),
value=f"<@{data.moderator_id}>"
)
if data.expiry:
timestamp = discord.utils.format_dt(data.expiry)
if data.ticket_state is TicketState.EXPIRING:
embed.add_field(
name=t(_p('ticket|field:expiry|mode:expiring|name', "Expires At")),
value=t(_p(
'ticket|field:expiry|mode:expiring|value',
"{timestamp}\nDuration: `{duration}`"
)).format(
timestamp=timestamp,
duration=strfdelta(dt.timedelta(seconds=data.duration))
),
)
elif data.ticket_state is TicketState.EXPIRED:
embed.add_field(
name=t(_p('ticket|field:expiry|mode:expired|name', "Expired")),
value=t(_p(
'ticket|field:expiry|mode:expired|value',
"{timestamp}"
)).format(
timestamp=timestamp,
),
)
else:
embed.add_field(
name=t(_p('ticket|field:expiry|mode:open|name', "Expiry")),
value=t(_p(
'ticket|field:expiry|mode:open|value',
"{timestamp}"
)).format(
timestamp=timestamp,
),
)
if data.context:
embed.add_field(
name=t(_p('ticket|field:context|name', "Context")),
value=data.context,
inline=False
)
if data.addendum:
embed.add_field(
name=t(_p('ticket|field:notes|name', "Notes")),
value=data.addendum,
inline=False
)
if data.ticket_state is TicketState.PARDONED:
embed.add_field(
name=t(_p('ticket|field:pardoned|name', "Pardoned")),
value=t(_p(
'ticket|field:pardoned|value',
"Pardoned by <&{moderator}> at {timestamp}.\n{reason}"
)).format(
moderator=data.pardoned_by,
timestamp=discord.utils.format_dt(timestamp),
reason=data.pardoned_reason or ''
),
inline=False
)
embed.set_footer(
text=f"ID: {data.targetid}"
)
return MessageArgs(embed=embed)
async def update(self, **kwargs):
"""
Update the ticket data.
`kwargs` are passed directly to the data update method.
Also handles updating the ticket message and rescheduling the
expiry, if applicable.
No error is raised if the ticket message cannot be updated.
This should generally be called using the correct Ticket
subclass, so that the ticket message args are correct.
"""
await self.data.update(**kwargs)
# TODO: Ticket post update and expiry update
await self.post()
async def post(self):
"""
Post or update the ticket in the ticket log.
"""
ticket_log = self.lguild.config.get(ModerationSettings.TicketLog.setting_id).value
ticket_log: discord.TextChannel
args = await self.make_message()
if ticket_log:
resend = True
if self.data.log_msg_id:
msg = ticket_log.get_partial_message(self.data.log_msg_id)
try:
await msg.edit(**args.edit_args)
resend = False
except discord.NotFound:
resend = True
except discord.HTTPException:
resend = True
if resend:
try:
msg = await ticket_log.send(**args.send_args)
except discord.HTTPException:
msg = None
await self.data.update(log_msg_id=msg.id if msg else None)
return None
async def cancel_expiry(self):
"""
Convenience method to cancel expiry of this ticket.
Typically used when another ticket overrides the current ticket.
Sets the ticket state to OPEN, so that it no longer expires.
"""
if self.data.ticket_state is TicketState.EXPIRING:
await self.data.update(ticket_state=TicketState.OPEN)
self.expiring.cancel_tasks(self.data.ticketid)
await self.post()
async def _revert(self):
raise NotImplementedError
async def _expire(self):
"""
Actual expiry method.
"""
if self.data.ticket_state == TicketState.EXPIRING:
logger.debug(
f"Expiring ticket '{self.data.ticketid}'."
)
try:
await self._revert(reason="Automatic Expiry.")
except Exception:
logger.warning(
"Revert failed during automatic ticket expiry. "
"This should not happen, revert should silently fail and log. "
f"Ticket data: {self.data}"
)
await self.data.update(ticket_state=TicketState.EXPIRED)
await self.post()
# TODO: Post an extra note to the modlog about the expiry.
async def revert(self):
"""
Revert this ticket.
"""
raise NotImplementedError
async def expire(self):
"""
Expire this ticket.
This is a publicly exposed API,
and the caller is responsible for checking that the ticket needs expiry.
"""
await self._expire()
async def pardon(self):
raise NotImplementedError

View File

@@ -0,0 +1,10 @@
import logging
from babel.translator import LocalBabel
logger = logging.getLogger(__name__)
babel = LocalBabel('video')
async def setup(bot):
from .cog import VideoCog
await bot.add_cog(VideoCog(bot))

View File

@@ -0,0 +1,575 @@
from typing import Optional
from collections import defaultdict
from weakref import WeakValueDictionary
import datetime as dt
import asyncio
import discord
from discord.ext import commands as cmds
from discord import app_commands as appcmds
from discord.app_commands import Range
from meta import LionCog, LionBot, LionContext
from meta.logger import log_wrap
from meta.sharding import THIS_SHARD
from core.data import CoreData
from utils.lib import utc_now
from wards import high_management_ward, low_management_ward, equippable_role
from modules.moderation.cog import ModerationCog
from . import babel, logger
from .data import VideoData
from .settings import VideoSettings
from .settingui import VideoSettingUI
from .ticket import VideoTicket
_p = babel._p
class VideoCog(LionCog):
def __init__(self, bot: LionBot):
self.bot = bot
self.data = bot.db.load_registry(VideoData())
self.settings = VideoSettings()
self.ready = asyncio.Event()
self._video_tasks: dict[tuple[int, int], asyncio.Task] = {}
self._event_locks: dict[tuple[int, int], asyncio.Lock] = WeakValueDictionary()
async def cog_load(self):
await self.data.init()
# TODO: Register Video Ticket type here
modcog = self.bot.get_cog('ModerationCog')
if modcog is None:
raise ValueError("Cannot load VideoCog before ModerationCog!")
self.bot.core.guild_config.register_model_setting(self.settings.VideoBlacklist)
self.bot.core.guild_config.register_model_setting(self.settings.VideoGracePeriod)
await self.settings.VideoChannels.setup(self.bot)
await self.settings.VideoExempt.setup(self.bot)
configcog = self.bot.get_cog('ConfigCog')
if configcog is None:
logger.warning(
"Could not load ConfigCog. VideoCog configuration will not crossload."
)
else:
self.crossload_group(self.configure_group, configcog.configure_group)
if self.bot.is_ready():
await self.initialise()
async def cog_unload(self):
...
@LionCog.listener('on_ready')
async def initialise(self):
"""
Read all current voice channel members.
Ensure that all video channel members have tasks running or are valid.
Note that we do start handling events before the bot cache is ready.
This is because the event data carries all required member data with it.
However, members who were already present and didn't fire an event
may still need to be handled.
"""
# Re-cache, now using the actual client guilds
await self.settings.VideoChannels.setup(self.bot)
await self.settings.VideoExempt.setup(self.bot)
# Collect members that need handling
active = [channel for guild in self.bot.guilds for channel in guild.voice_channels if channel.members]
tasks = []
for channel in active:
if await self.check_video_channel(channel):
for member in list(channel.members):
key = (channel.guild.id, member.id)
async with self.event_lock(key):
if key in self._video_tasks:
pass
elif await self.check_member_exempt(member):
pass
elif await self.check_member_blacklist(member):
task = asyncio.create_task(
self._remove_blacklisted(member, channel)
)
tasks.append(task)
else:
task = asyncio.create_task(
self._joined_video_channel(member, channel)
)
tasks.append(task)
self._video_tasks[key] = task
if tasks:
await asyncio.gather(*tasks)
# ----- Event Handlers -----
def event_lock(self, key) -> asyncio.Lock:
"""
Get an asyncio.Lock for the given key.
Guarantees sequential event handling.
"""
lock = self._event_locks.get(key, None)
if lock is None:
lock = self._event_locks[key] = asyncio.Lock()
logger.debug(f"Getting video event lock {key} (locked: {lock.locked()})")
return lock
@LionCog.listener('on_voice_state_update')
@log_wrap(action='Video Watchdog')
async def video_watchdog(self, member: discord.Member,
before: discord.VoiceState, after: discord.VoiceState):
if member.bot:
return
task_key = (member.guild.id, member.id)
# Freeze the state so it doesn't get updated by other events
after_channel = after.channel
before_channel = before.channel
after_video = after.self_video
async with self.event_lock(task_key):
if after_channel != before_channel:
# Channel changed, cancel any running tasks
task = self._video_tasks.pop(task_key, None)
if task and not task.done() and not task.cancelled():
task.cancel()
# If they are joining a video channel, run join logic
run_join = (
after_channel and not after_video
and await self.check_video_channel(after_channel)
and not await self.check_member_exempt(member)
)
if run_join:
# Check if the member is blacklisted
if await self.check_member_blacklist(member):
# Kick them from the channel
await self._remove_blacklisted(member, after_channel)
join_task = asyncio.create_task(
self._joined_video_channel(member, after_channel)
)
self._video_tasks[task_key] = join_task
logger.debug(
f"Launching video channel join task for <uid:{member.id}> "
f"in <cid:{after_channel.id}> of guild <gid:{member.guild.id}>."
)
elif after_channel and (before.self_video != after_video):
# Video state changed
channel = after_channel
if (await self.check_video_channel(channel) and not await self.check_member_exempt(member)):
# Relevant video event
if after_video:
# They turned their video on!
# Cancel any running tasks
task = self._video_tasks.pop(task_key, None)
if task and not task.done() and not task.cancelled():
task.cancel()
elif (task := self._video_tasks.get(task_key, None)) is None or task.done():
# They turned their video off, and there are no tasks handling the member
# Give them a brief grace period and then kick them
kick_task = asyncio.create_task(
self._disabled_video_kick(member, channel)
)
self._video_tasks[task_key] = kick_task
logger.debug(
f"Launching video channel kick task for <uid:{member.id}> "
f"in <cid:{channel.id}> of guild <gid:{member.guild.id}>"
)
async def check_member_exempt(self, member: discord.Member) -> bool:
"""
Check whether a member is video-exempt.
Should almost always hit cache.
"""
exempt_setting = await self.settings.VideoExempt.get(member.guild.id)
exempt_ids = set(exempt_setting.data)
return any(role.id in exempt_ids for role in member.roles)
async def check_member_blacklist(self, member: discord.Member) -> bool:
"""
Check whether a member is video blacklisted.
(i.e. check whether they have the blacklist role)
"""
blacklistid = (await self.settings.VideoBlacklist.get(member.guild.id)).data
return (blacklistid and any(role.id == blacklistid for role in member.roles))
async def check_video_channel(self, channel: discord.VoiceChannel) -> bool:
"""
Check whether a given channel is a video only channel.
Should almost always hit cache.
"""
channel_setting = await self.settings.VideoChannels.get(channel.guild.id)
channelids = set(channel_setting.data)
return (channel.id in channelids) or (channel.category_id and channel.category_id in channelids)
async def _remove_blacklisted(self, member: discord.Member, channel: discord.VoiceChannel):
"""
Remove a video blacklisted member from the channel.
"""
logger.info(
f"Removing video blacklisted member <uid:{member.id}> from <cid:{channel.id}> in "
f"<gid:{member.guild.id}>"
)
t = self.bot.translator.t
try:
# Kick the member from the channel
await asyncio.shield(
member.edit(
voice_channel=None,
reason=t(_p(
'video_watchdog|kick_blacklisted_member|audit_reason',
"Removing video blacklisted member from a video channel."
))
)
)
except discord.HTTPException:
# TODO: Event log
...
except asyncio.CancelledError:
# This shouldn't happen because we don't wait for this task the same way
# And the event lock should wait for this to be complete anyway
pass
# TODO: Notify through the moderation alert API
embed = discord.Embed(
colour=discord.Colour.brand_red(),
title=t(_p(
'video_watchdog|kick_blacklisted_member|notification|title',
"You have been disconnected."
)),
description=t(_p(
'video_watchdog|kick_blacklisted_member|notification|desc',
"You were disconnected from the video channel {channel} because you are "
"blacklisted from video channels in **{server}**."
)).format(channel=channel.mention, server=channel.guild.name),
)
modcog: ModerationCog = self.bot.get_cog('ModerationCog')
await modcog.send_alert(
member,
embed=embed
)
async def _joined_video_channel(self, member: discord.Member, channel: discord.VoiceChannel):
"""
Handle a (non-exempt, non-blacklisted) member joining a video channel.
"""
if not member.voice or not member.voice.channel:
# In case the member already left
return
if member.voice.self_video:
# In case they already turned video on
return
try:
# First wait for 15 seconds for them to turn their video on (without prompting)
await asyncio.sleep(15)
# Fetch the required setting data (allow cancellation while we fetch or create)
lion = await self.bot.core.lions.fetch_member(member.guild.id, member.id)
except asyncio.CancelledError:
# They left the video channel or turned their video on
return
t = self.bot.translator.t
modcog: ModerationCog = self.bot.get_cog('ModerationCog')
now = utc_now()
# Important that we use a sync request here
grace = lion.lguild.config.get(self.settings.VideoGracePeriod.setting_id).value
disconnect_at = now + dt.timedelta(seconds=grace)
jump_field = t(_p(
'video_watchdog|join_task|jump_field',
"[Click to jump back]({link})"
)).format(link=channel.jump_url)
request = discord.Embed(
colour=discord.Colour.orange(),
title=t(_p(
'video_watchdog|join_task|initial_request:title',
"Please enable your video!"
)),
description=t(_p(
'video_watchdog|join_task|initial_request:description',
"**You have joined the video channel {channel}!**\n"
"Please **enable your video** or **leave the channel** "
"or you will be disconnected {timestamp} and "
"potentially **blacklisted**."
)).format(
channel=channel.mention,
timestamp=discord.utils.format_dt(disconnect_at, 'R'),
),
timestamp=now
).add_field(name='', value=jump_field)
thanks = discord.Embed(
colour=discord.Colour.brand_green(),
title=t(_p(
'video_watchdog|join_task|thanks:title',
"Thanks for enabling your video!"
)),
).add_field(name='', value=jump_field)
bye = discord.Embed(
colour=discord.Colour.brand_green(),
title=t(_p(
'video_watchdog|join_task|bye:title',
"Thanks for leaving the channel promptly!"
))
)
alert_task = asyncio.create_task(
modcog.send_alert(
member,
embed=request
)
)
try:
message = await asyncio.shield(alert_task)
await discord.utils.sleep_until(disconnect_at)
except asyncio.CancelledError:
# Member enabled video or moved to another channel or left the server
# Wait for the message to finish sending if we need to
message = await alert_task
# Fetch a new member to check voice state
member = member.guild.get_member(member.id)
if member and message:
if member.voice and (member.voice.channel == channel) and member.voice.self_video:
# Assume member enabled video
embed = thanks
else:
# Assume member left channel
embed = bye
embed.timestamp = utc_now()
try:
await message.edit(embed=embed)
except discord.HTTPException:
pass
else:
# Member never enabled video in the grace period.
# No longer accept cancellation
self._video_tasks.pop((member.guild.id, member.id), None)
# Disconnect user
try:
await member.edit(
voice_channel=None,
reason=t(_p(
'video_watchdog|join_task|kick_after_grace|audit_reason',
"Member never enabled their video in video channel."
))
)
except discord.HTTPException:
# TODO: Event log
...
# Assign warn/blacklist ticket as needed
blacklist = lion.lguild.config.get(self.settings.VideoBlacklist.setting_id)
only_warn = (not lion.data.video_warned) and blacklist
ticket = None
if not only_warn:
# Try to apply blacklist
try:
ticket = await self.blacklist_member(
member,
reason=t(_p(
'video_watchdog|join_task|kick_after_grace|ticket_reason',
"Failed to enable their video in time in the video channel {channel}"
)).format(channel=channel.mention)
)
except discord.HTTPException as e:
logger.debug(
f"Could not create blacklist ticket on member <uid:{member.id}> "
f"in <gid:{member.guild.id}>: {e.text}"
)
only_warn = True
# Ack based on ticket created
alert_ref = message.to_reference(fail_if_not_exists=False)
if only_warn:
# TODO: Warn ticket
warning = discord.Embed(
colour=discord.Colour.brand_red(),
title=t(_p(
'video_watchdog|join_task|kick_after_grace|warning|title',
"You have received a warning!"
)),
description=t(_p(
'video_watchdog|join_task|kick_after_grace|warning|desc',
"**You must enable your camera in camera-only rooms.**\n"
"You have been disconnected from the video {channel} for not "
"enabling your camera."
)).format(channel=channel.mention),
timestamp=utc_now()
).add_field(name='', value=jump_field)
await modcog.send_alert(member, embed=warning, reference=alert_ref)
if not lion.data.video_warned:
await lion.data.update(video_warned=True)
else:
alert = discord.Embed(
colour=discord.Colour.brand_red(),
title=t(_p(
'video_watchdog|join_task|kick_after_grace|blacklist|title',
"You have been blacklisted!"
)),
description=t(_p(
'video_watchdog|join_task|kick_after_grace|blacklist|desc',
"You have been blacklisted from the video channels in this server."
)),
timestamp=utc_now()
).add_field(name='', value=jump_field)
# TODO: Add duration
await modcog.send_alert(member, embed=alert, reference=alert_ref)
async def _disabled_video_kick(self, member: discord.Member, channel: discord.VoiceChannel):
"""
Kick a video channel member who has disabled their video.
"""
# Give them 15 seconds to re-enable
try:
await asyncio.sleep(15)
except asyncio.CancelledError:
# Member left the channel or turned on their video
return
# Member did not turn on their video, actually kick and notify
t = self.bot.translator.t
logger.info(
f"Removing member <uid:{member.id}> from video channel <cid:{channel.id}> in "
f"<gid:{member.guild.id}> because they disabled their video."
)
# Disconnection is now inevitable
# We also don't want our own disconnection to cancel the task
self._video_tasks.pop((member.guild.id, member.id), None)
try:
await asyncio.shield(
member.edit(
voice_channel=None,
reason=t(_p(
'video_watchdog|disabled_video_kick|audit_reason',
"Disconnected for disabling video for more than {number} seconds in video channel."
)).format(number=15)
)
)
except asyncio.CancelledError:
# Ignore cancelled error at this point
pass
except discord.HTTPException:
# TODO: Event logging
pass
embed = discord.Embed(
colour=discord.Colour.brand_red(),
title=t(_p(
'video_watchdog|disabled_video_kick|notification|title',
"You have been disconnected."
)),
description=t(_p(
'video_watchdog|disabled_video_kick|notification|desc',
"You were disconnected from the video channel {channel} because "
"you disabled your video.\n"
"Please keep your video on at all times, and leave the channel if you need "
"to disable it!"
))
)
modcog: ModerationCog = self.bot.get_cog('ModerationCog')
await modcog.send_alert(
member,
embed=embed
)
async def blacklist_member(self, member: discord.Member, reason: str):
"""
Create a VideoBlacklist ticket with the appropriate duration,
and apply the video blacklist role.
Propagates any exceptions that may arise.
"""
return await VideoTicket.autocreate(
self.bot, member, reason
)
# ----- Commands -----
# ------ Configuration -----
@LionCog.placeholder_group
@cmds.hybrid_group('configure', with_app_command=False)
async def configure_group(self, ctx: LionContext):
...
@configure_group.command(
name=_p('cmd:configure_video', "video_channels"),
description=_p(
'cmd:configure_video|desc', "Configure video-only channels and blacklisting."
)
)
@appcmds.rename(
video_blacklist=VideoSettings.VideoBlacklist._display_name,
video_blacklist_durations=VideoSettings.VideoBlacklistDurations._display_name,
video_grace_period=VideoSettings.VideoGracePeriod._display_name,
)
@appcmds.describe(
video_blacklist=VideoSettings.VideoBlacklist._desc,
video_blacklist_durations=VideoSettings.VideoBlacklistDurations._desc,
video_grace_period=VideoSettings.VideoGracePeriod._desc,
)
@low_management_ward
async def configure_video(self, ctx: LionContext,
video_blacklist: Optional[discord.Role] = None,
video_blacklist_durations: Optional[str] = None,
video_grace_period: Optional[str] = None,
):
if not ctx.guild:
return
if not ctx.interaction:
return
await ctx.interaction.response.defer(thinking=True)
modified = []
if video_blacklist is not None:
await equippable_role(self.bot, video_blacklist, ctx.author)
setting = self.settings.VideoBlacklist
await setting._check_value(ctx.guild.id, video_blacklist)
instance = setting(ctx.guild.id, video_blacklist.id)
modified.append(instance)
if video_blacklist_durations is not None:
setting = self.settings.VideoBlacklistDurations
instance = await setting.from_string(ctx.guild.id, video_blacklist_durations)
modified.append(instance)
if video_grace_period is not None:
setting = self.settings.VideoGracePeriod
instance = await setting.from_string(ctx.guild.id, video_grace_period)
modified.append(instance)
if modified:
ack_lines = []
for instance in modified:
await instance.write()
ack_lines.append(instance.update_message)
# Ack modified
tick = self.bot.config.emojis.tick
embed = discord.Embed(
colour=discord.Colour.brand_green(),
description='\n'.join(f"{tick} {line}" for line in ack_lines),
)
await ctx.reply(embed=embed)
if ctx.channel.id not in VideoSettingUI._listening or not modified:
ui = VideoSettingUI(self.bot, ctx.guild.id, ctx.channel.id)
await ui.run(ctx.interaction)
await ui.wait()

View File

@@ -0,0 +1,7 @@
from data import Registry, Table
class VideoData(Registry):
video_channels = Table('video_channels')
video_exempt_roles = Table('video_exempt_roles')
video_blacklist_durations = Table('studyban_durations')

View File

@@ -0,0 +1,315 @@
from cachetools import LRUCache
from collections import defaultdict
from settings import ModelData, ListData
from settings.groups import SettingGroup
from settings.ui import InteractiveSetting
from settings.setting_types import (
DurationSetting, RoleSetting, RoleListSetting, ChannelListSetting,
ListSetting
)
from meta import conf
from meta.sharding import THIS_SHARD
from meta.logger import log_wrap
from core.data import CoreData
from babel.translator import ctx_translator
from . import babel, logger
from .data import VideoData
_p = babel._p
class VideoSettings(SettingGroup):
class VideoChannels(ListData, ChannelListSetting):
setting_id = "video_channels"
_event = 'guildset_video_channels'
_display_name = _p('guildset:video_channels', "video_channels")
_desc = _p(
'guildset:video_channels|desc',
"List of voice channels and categories in which to enforce video."
)
_long_desc = _p(
'guildset:video_channels|long_desc',
"Member will be required to turn on their video in these channels.\n"
"If they do not enable their video with `15` seconds of joining, "
"they will be asked to enable it "
"through a notification in direct messages or the `alert_channel`. "
"If they still have not enabled it after the `video_grace_period` has passed, "
"they will be kicked from the channel. "
"Further, after the first offence (which is considered a warning), "
"they will be given the `video_blacklist` role, if configured, "
"which will stop them from joining video channels.\n"
"As usual, if a category is configured, this will apply to all voice channels "
"under the category."
)
_accepts = _p(
'guildset:video_channels|accepts',
"Comma separated channel ids or names."
)
_cache = LRUCache(maxsize=2500)
_table_interface = VideoData.video_channels
_id_column = 'guildid'
_data_column = 'channelid'
_order_column = 'channelid'
@property
def update_message(self) -> str:
t = ctx_translator.get().t
value = self.value
if value:
resp = t(_p(
'guildset:video_channels|set_response:set',
"Members will be asked to turn on their video in the following channels: {channels}"
)).format(channels=self.formatted)
else:
resp = t(_p(
'guildset:video_channels|set_response:unset',
"Members will not be asked to turn on their video in any channels."
))
return resp
@classmethod
@log_wrap(action="Cache video_channels")
async def setup(cls, bot):
"""
Preload video channels for every guild on the current shard.
"""
data: VideoData = bot.db.registries[VideoData._name]
if bot.is_ready():
rows = await data.video_channels.select_where(
guildid=[guild.id for guild in bot.guilds]
)
else:
rows = await data.video_channels.select_where(THIS_SHARD)
new_cache = defaultdict(list)
count = 0
for row in rows:
new_cache[row['guildid']].append(row['channelid'])
count += 1
if cls._cache is None:
cls._cache = LRUCache(2500)
cls._cache.clear()
cls._cache.update(new_cache)
logger.info(f"Loaded {count} video channels on this shard.")
class VideoBlacklist(ModelData, RoleSetting):
setting_id = "video_blacklist"
_event = 'guildset_video_blacklist'
_display_name = _p('guildset:video_blacklist', "video_blacklist")
_desc = _p(
'guildset:video_blacklist|desc',
"Role given when members are blacklisted from video channels."
)
_long_desc = _p(
'guildset:video_blacklist|long_desc',
"This role will be automatically given after a member has failed to keep their video "
"enabled in a video channel (see above).\n"
"Members who have this role will not be able to join configured video channels. "
"The role permissions may be freely configured by server admins "
"to place further restrictions on the offender.\n"
"The role may also be manually assigned, to the same effect.\n"
"If this role is not set, no video blacklist will occur, "
"and members will only be kicked from the channel and warned."
)
_accepts = _p(
'guildset:video_blacklist|accepts',
"Blacklist role name or id."
)
_default = None
_model = CoreData.Guild
_column = CoreData.Guild.studyban_role.name
_allow_object = False
@property
def update_message(self) -> str:
t = ctx_translator.get().t
value = self.value
if value:
resp = t(_p(
'guildset:video_blacklist|set_response:set',
"Members who fail to keep their video on will be given {role}"
)).format(role=f"<@&{self.data}>")
else:
resp = t(_p(
'guildset:video_blacklist|set_response:unset',
"Members will no longer be automatically blacklisted from video channels."
))
return resp
@classmethod
def _format_data(cls, parent_id, data, **kwargs):
t = ctx_translator.get().t
if data is not None:
return super()._format_data(parent_id, data, **kwargs)
else:
return t(_p(
'guildset:video_blacklist|formatted:unset',
"Not Set. (Members will not be automatically blacklisted.)"
))
class VideoBlacklistDurations(ListData, ListSetting, InteractiveSetting):
setting_id = 'video_durations'
_setting = DurationSetting
_display_name = _p('guildset:video_durations', "video_blacklist_durations")
_desc = _p(
'guildset:video_durations|desc',
"Sequence of durations for automatic video blacklists."
)
_long_desc = _p(
'guildset:video_durations|long_desc',
"When `video_blacklist` is set and members fail to turn on their video within "
"the configured `video_grace_period`, they will be automatically blacklisted "
"(i.e. given the `video_blacklist` role).\n"
"This setting describes *how long* the member will be blacklisted for, "
"for each offence.\n"
"E.g. if this is set to `1d, 7d, 30d`, "
"then on the first offence the member will be blacklisted for 1 day, "
"on the second for 7 days, and on the third for 30 days. "
"A subsequent offence will result in an infinite blacklist."
)
_accepts = _p(
'guildset:video_durations|accepts',
"Comma separated list of durations."
)
_default = [
5 * 60,
60 * 60,
6 * 60 * 60,
24 * 60 * 60,
168 * 60 * 60,
720 * 60 * 60
]
# No need to expire
_cache = {}
_table_interface = VideoData.video_blacklist_durations
_id_column = 'guildid'
_data_column = 'duration'
_order_column = 'rowid'
@property
def update_message(self) -> str:
t = ctx_translator.get().t
value = self.value
if value:
resp = t(_p(
'guildset:video_durations|set_response:set',
"Members will be automatically blacklisted for: {durations}"
)).format(durations=self.formatted)
else:
resp = t(_p(
'guildset:video_durations|set_response:unset',
"Video blacklists are now always permanent."
))
return resp
class VideoGracePeriod(ModelData, DurationSetting):
setting_id = "video_grace_period"
_event = 'guildset_video_grace_period'
_display_name = _p('guildset:video_grace_period', "video_grace_period")
_desc = _p(
'guildset:video_grace_period|desc',
"How long to wait (in seconds) before kicking/blacklist members who don't enable their video."
)
_long_desc = _p(
'guildset:video_grace_period|long_desc',
"The length of time a member has to enable their video after joining a video channel. "
"After this time, if they have not enabled their video, they will be kicked from the channel "
"and potentially blacklisted from video channels."
)
_accepts = _p(
'guildset:video_grace_period|accepts',
"How many seconds to wait for a member to enable video."
)
_default = 90
_default_multiplier = 1
_model = CoreData.Guild
_column = CoreData.Guild.video_grace_period.name
_cache = LRUCache(2500)
@property
def update_message(self) -> str:
t = ctx_translator.get().t
resp = t(_p(
'guildset:video_grace_period|set_response:set',
"Members will now have **{duration}** to enable their video."
)).format(duration=self.formatted)
return resp
class VideoExempt(ListData, RoleListSetting):
setting_id = "video_exempt"
_event = 'guildset_video_exempt'
_display_name = _p('guildset:video_exempt', "video_exempt")
_desc = _p(
'guildset:video_exempt|desc',
"List of roles which are exempt from video channels."
)
_long_desc = _p(
'guildset:video_exempt|long_desc',
"Members who have **any** of these roles "
"will not be required to enable their video in the `video_channels`. "
"This also overrides the `video_blacklist` role."
)
_accepts = _p(
'guildset:video_exempt|accepts',
"List of exempt role names or ids."
)
_table_interface = VideoData.video_exempt_roles
_id_column = 'guildid'
_data_column = 'roleid'
_order_column = 'roleid'
@property
def update_message(self) -> str:
t = ctx_translator.get().t
value = self.value
if value:
resp = t(_p(
'guildset:video_exempt|set_response:set',
"The following roles will now be exempt from video channels: {roles}"
)).format(roles=self.formatted)
else:
resp = t(_p(
'guildset:video_exempt|set_response:unset',
"No members will be exempt from video channel requirements."
))
return resp
@classmethod
@log_wrap(action="Cache video_exempt")
async def setup(cls, bot):
"""
Preload video exempt roles for every guild on the current shard.
"""
data: VideoData = bot.db.registries[VideoData._name]
if bot.is_ready():
rows = await data.video_exempt_roles.select_where(
guildid=[guild.id for guild in bot.guilds]
)
else:
rows = await data.video_exempt_roles.select_where(THIS_SHARD)
new_cache = defaultdict(list)
count = 0
for row in rows:
new_cache[row['guildid']].append(row['roleid'])
count += 1
if cls._cache is None:
cls._cache = LRUCache(2500)
cls._cache.clear()
cls._cache.update(new_cache)
logger.info(f"Loaded {count} video exempt roles on this shard.")

View File

@@ -0,0 +1,163 @@
import asyncio
import discord
from discord.ui.button import button, Button, ButtonStyle
from discord.ui.select import select, ChannelSelect, RoleSelect
from meta import LionBot
from wards import equippable_role
from utils.ui import ConfigUI, DashboardSection
from utils.lib import MessageArgs
from . import babel
from .settings import VideoSettings
_p = babel._p
class VideoSettingUI(ConfigUI):
setting_classes = (
VideoSettings.VideoChannels,
VideoSettings.VideoExempt,
VideoSettings.VideoGracePeriod,
VideoSettings.VideoBlacklist,
VideoSettings.VideoBlacklistDurations,
)
def __init__(self, bot: LionBot, guildid: int, channelid: int, **kwargs):
self.settings = bot.get_cog('VideoCog').settings
super().__init__(bot, guildid, channelid, **kwargs)
# ----- UI Components -----
# Video Channels channel selector
@select(
cls=ChannelSelect,
channel_types=[discord.ChannelType.voice, discord.ChannelType.category],
placeholder="CHANNELS_MENU_PLACEHOLDER",
min_values=0, max_values=25
)
async def channels_menu(self, selection: discord.Interaction, selected: RoleSelect):
"""
Multi-channel selector for the `video_channels` setting.
"""
await selection.response.defer(thinking=True, ephemeral=True)
setting = self.get_instance(VideoSettings.VideoChannels)
setting.value = selected.values
await setting.write()
await selection.delete_original_response()
async def channels_menu_refresh(self):
menu = self.channels_menu
t = self.bot.translator.t
menu.placeholder = t(_p(
'ui:video_config|menu:channels|placeholder',
"Select Video Channels"
))
# Video exempt role selector
@select(
cls=RoleSelect,
placeholder="EXEMPT_MENU_PLACEHOLDER",
min_values=0, max_values=25
)
async def exempt_menu(self, selection: discord.Interaction, selected: RoleSelect):
"""
Multi-role selector for the `video_exempt` setting.
"""
await selection.response.defer(thinking=True, ephemeral=True)
setting = self.get_instance(VideoSettings.VideoExempt)
setting.value = selected.values
await setting.write()
await selection.delete_original_response()
async def exempt_menu_refresh(self):
menu = self.exempt_menu
t = self.bot.translator.t
menu.placeholder = t(_p(
'ui:video_config|menu:exempt|placeholder',
"Select Exempt Roles"
))
# Video blacklist role selector
@select(
cls=RoleSelect,
placeholder="VIDEO_BLACKLIST_MENU_PLACEHOLDER",
min_values=0, max_values=1
)
async def video_blacklist_menu(self, selection: discord.Interaction, selected: RoleSelect):
"""
Single role selector for the `video_blacklist` setting.
"""
await selection.response.defer(thinking=True, ephemeral=True)
setting = self.get_instance(VideoSettings.VideoBlacklist)
setting.value = selected.values[0] if selected.values else None
if setting.value:
await equippable_role(self.bot, setting.value, selection.user)
await setting.write()
await selection.delete_original_response()
async def video_blacklist_menu_refresh(self):
menu = self.video_blacklist_menu
t = self.bot.translator.t
menu.placeholder = t(_p(
'ui:video_config|menu:video_blacklist|placeholder',
"Select Blacklist Role"
))
# ----- UI Flow -----
async def make_message(self) -> MessageArgs:
t = self.bot.translator.t
title = t(_p(
'ui:video_config|embed|title',
"Video Channel Configuration Panel"
))
embed = discord.Embed(
title=title,
colour=discord.Colour.orange()
)
for setting in self.instances:
embed.add_field(**setting.embed_field, inline=False)
return MessageArgs(embed=embed)
async def reload(self):
self.instances = [
await setting.get(self.guildid)
for setting in self.setting_classes
]
async def refresh_components(self):
component_refresh = (
self.edit_button_refresh(),
self.close_button_refresh(),
self.reset_button_refresh(),
self.channels_menu_refresh(),
self.exempt_menu_refresh(),
self.video_blacklist_menu_refresh(),
)
await asyncio.gather(*component_refresh)
self.set_layout(
(self.channels_menu,),
(self.exempt_menu,),
(self.video_blacklist_menu,),
(self.edit_button, self.reset_button, self.close_button,),
)
class VideoDashboard(DashboardSection):
section_name = _p(
"dash:video|title",
"Video Channel Settings ({commands[configure video_channels]})"
)
_option_name = _p(
"dash:video|option|name",
"Video Channel Panel"
)
configui = VideoSettingUI
setting_classes = VideoSettingUI.setting_classes

View File

@@ -0,0 +1,110 @@
import datetime as dt
import discord
from meta import LionBot
from utils.lib import utc_now
from modules.moderation.cog import ModerationCog
from modules.moderation.data import TicketType, TicketState, ModerationData
from modules.moderation.ticket import Ticket, ticket_factory
from . import babel, logger
from .settings import VideoSettings
@ticket_factory(TicketType.STUDY_BAN)
class VideoTicket(Ticket):
__slots__ = ()
@classmethod
async def create(
cls, bot: LionBot, member: discord.Member,
moderatorid: int, reason: str, expiry=None,
**kwargs
):
modcog: ModerationCog = bot.get_cog('ModerationCog')
ticket_data = await modcog.data.Ticket.create(
guildid=member.guild.id,
targetid=member.id,
ticket_type=TicketType.STUDY_BAN,
ticket_state=TicketState.EXPIRING if expiry else TicketState.OPEN,
moderator_id=moderatorid,
auto=(moderatorid == bot.user.id),
content=reason,
expiry=expiry,
**kwargs
)
lguild = await bot.core.lions.fetch_guild(member.guild.id, guild=member.guild)
new_ticket = cls(lguild, ticket_data)
# Schedule expiry if required
if expiry:
cls.expiring.schedule_task(ticket_data.ticketid, expiry.timestamp())
await new_ticket.post()
# Cancel any existent expiring video blacklists
tickets = await cls.fetch_tickets(
bot,
(modcog.data.Ticket.ticketid != new_ticket.data.ticketid),
guildid=member.guild.id,
targetid=member.id,
ticket_state=TicketState.EXPIRING
)
for ticket in tickets:
await ticket.cancel_expiry()
return new_ticket
@classmethod
async def autocreate(cls, bot: LionBot, target: discord.Member, reason: str, **kwargs):
modcog: ModerationCog = bot.get_cog('ModerationCog')
lguild = await bot.core.lions.fetch_guild(target.guild.id, guild=target.guild)
blacklist = lguild.config.get(VideoSettings.VideoBlacklist.setting_id).value
if not blacklist:
return
# This will propagate HTTPException if needed
await target.add_roles(blacklist, reason=reason)
Ticket = modcog.data.Ticket
row = await Ticket.table.select_one_where(
(Ticket.ticket_state != TicketState.PARDONED),
guildid=target.guild.id,
targetid=target.id,
ticket_type=TicketType.STUDY_BAN,
).with_no_adapter().select(ticket_count="COUNT(*)")
count = row[0]['ticket_count'] if row else 0
durations = (await VideoSettings.VideoBlacklistDurations.get(target.guild.id)).value
if count < len(durations):
durations.sort()
duration = durations[count]
expiry = utc_now() + dt.timedelta(seconds=duration)
else:
duration = None
expiry = None
return await cls.create(
bot, target,
bot.user.id, reason,
duration=duration, expiry=expiry,
**kwargs
)
async def _revert(self, reason=None):
target = self.target
blacklist = self.lguild.config.get(VideoSettings.VideoBlacklist.setting_id).value
# TODO: User lion.remove_role instead
if target and blacklist in target.roles:
try:
await target.remove_roles(
blacklist,
reason=reason
)
except discord.HTTPException as e:
logger.debug(f"Revert failed for ticket {self.data.ticketid}: {e.text}")

View File

@@ -367,6 +367,7 @@ class RoleSetting(InteractiveSetting[ParentID, int, Union[discord.Role, discord.
_accepts = _p('settype:role|accepts', "A role name or id")
_selector_placeholder = "Select a Role"
_allow_object = True
@classmethod
def _get_guildid(cls, parent_id: int, **kwargs) -> int:
@@ -399,7 +400,7 @@ class RoleSetting(InteractiveSetting[ParentID, int, Union[discord.Role, discord.
guild = bot.get_guild(guildid)
if guild is not None:
role = guild.get_role(data)
if role is None:
if role is None and cls._allow_object:
role = discord.Object(id=data)
return role

View File

@@ -72,7 +72,7 @@ class TaskMonitor(Generic[Taskid]):
wake = self._taskmap[nextid] >= timestamp
wake = wake or taskid == nextid
else:
wake = False
wake = True
if taskid in self._taskmap:
self._tasklist.remove(taskid)
self._taskmap[taskid] = timestamp

View File

@@ -281,6 +281,8 @@ class DashboardSection:
setting_classes = []
configui = None
_option_name = None
def __init__(self, bot: LionBot, guildid: int):
self.bot = bot
self.guildid = guildid
@@ -289,6 +291,16 @@ class DashboardSection:
# Populated in load()
self.instances = []
@property
def option_name(self) -> str:
t = self.bot.translator.t
string = self._option_name or self.section_name
return t(string).format(
bot=self.bot,
commands=self.bot.core.mention_cache
)
async def load(self):
"""
Initialise the contained settings.