diff --git a/data/migration/v12-13/migration.sql b/data/migration/v12-13/migration.sql index f78851fa..71dbe2e2 100644 --- a/data/migration/v12-13/migration.sql +++ b/data/migration/v12-13/migration.sql @@ -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; diff --git a/data/migration/v12-13/moderation.sql b/data/migration/v12-13/moderation.sql new file mode 100644 index 00000000..d6e02e01 --- /dev/null +++ b/data/migration/v12-13/moderation.sql @@ -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) +); diff --git a/src/meta/logger.py b/src/meta/logger.py index a38a1589..ba76cf82 100644 --- a/src/meta/logger.py +++ b/src/meta/logger.py @@ -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) diff --git a/src/modules/__init__.py b/src/modules/__init__.py index c71bd2af..45644611 100644 --- a/src/modules/__init__.py +++ b/src/modules/__init__.py @@ -15,6 +15,8 @@ active = [ '.rooms', '.rolemenus', '.member_admin', + '.moderation', + '.video_channels', '.meta', '.test', ] diff --git a/src/modules/config/dashboard.py b/src/modules/config/dashboard.py index 767a840f..df0a4590 100644 --- a/src/modules/config/dashboard.py +++ b/src/modules/config/dashboard.py @@ -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) diff --git a/src/modules/member_admin/settingui.py b/src/modules/member_admin/settingui.py index 342b1775..5c9959cb 100644 --- a/src/modules/member_admin/settingui.py +++ b/src/modules/member_admin/settingui.py @@ -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 diff --git a/src/modules/moderation/__init__.py b/src/modules/moderation/__init__.py new file mode 100644 index 00000000..31633c42 --- /dev/null +++ b/src/modules/moderation/__init__.py @@ -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)) diff --git a/src/modules/moderation/cog.py b/src/modules/moderation/cog.py new file mode 100644 index 00000000..d6736de9 --- /dev/null +++ b/src/modules/moderation/cog.py @@ -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() diff --git a/src/modules/moderation/data.py b/src/modules/moderation/data.py new file mode 100644 index 00000000..77170993 --- /dev/null +++ b/src/modules/moderation/data.py @@ -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() diff --git a/src/modules/moderation/settings.py b/src/modules/moderation/settings.py new file mode 100644 index 00000000..44343886 --- /dev/null +++ b/src/modules/moderation/settings.py @@ -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." + )) diff --git a/src/modules/moderation/settingui.py b/src/modules/moderation/settingui.py new file mode 100644 index 00000000..13a6e456 --- /dev/null +++ b/src/modules/moderation/settingui.py @@ -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 diff --git a/src/modules/moderation/ticket.py b/src/modules/moderation/ticket.py new file mode 100644 index 00000000..4c8cba1a --- /dev/null +++ b/src/modules/moderation/ticket.py @@ -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 diff --git a/src/modules/video_channels/__init__.py b/src/modules/video_channels/__init__.py new file mode 100644 index 00000000..3105b5de --- /dev/null +++ b/src/modules/video_channels/__init__.py @@ -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)) diff --git a/src/modules/video_channels/cog.py b/src/modules/video_channels/cog.py new file mode 100644 index 00000000..4f2fa629 --- /dev/null +++ b/src/modules/video_channels/cog.py @@ -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 " + f"in of guild ." + ) + 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 " + f"in of guild " + ) + + 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 from in " + f"" + ) + 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 " + f"in : {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 from video channel in " + f" 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() + diff --git a/src/modules/video_channels/data.py b/src/modules/video_channels/data.py new file mode 100644 index 00000000..5274b3dd --- /dev/null +++ b/src/modules/video_channels/data.py @@ -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') diff --git a/src/modules/video_channels/settings.py b/src/modules/video_channels/settings.py new file mode 100644 index 00000000..82fe58c4 --- /dev/null +++ b/src/modules/video_channels/settings.py @@ -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.") diff --git a/src/modules/video_channels/settingui.py b/src/modules/video_channels/settingui.py new file mode 100644 index 00000000..399fe72f --- /dev/null +++ b/src/modules/video_channels/settingui.py @@ -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 diff --git a/src/modules/video_channels/ticket.py b/src/modules/video_channels/ticket.py new file mode 100644 index 00000000..b19a7fc5 --- /dev/null +++ b/src/modules/video_channels/ticket.py @@ -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}") diff --git a/src/settings/setting_types.py b/src/settings/setting_types.py index 694217fa..01b8eac6 100644 --- a/src/settings/setting_types.py +++ b/src/settings/setting_types.py @@ -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 diff --git a/src/utils/monitor.py b/src/utils/monitor.py index 9c3613a7..79ed8209 100644 --- a/src/utils/monitor.py +++ b/src/utils/monitor.py @@ -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 diff --git a/src/utils/ui/config.py b/src/utils/ui/config.py index 6c118622..a126c0cd 100644 --- a/src/utils/ui/config.py +++ b/src/utils/ui/config.py @@ -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.