rewrite: New Video channels and moderation.
This commit is contained in:
@@ -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;
|
||||
|
||||
10
data/migration/v12-13/moderation.sql
Normal file
10
data/migration/v12-13/moderation.sql
Normal 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)
|
||||
);
|
||||
@@ -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)
|
||||
|
||||
@@ -15,6 +15,8 @@ active = [
|
||||
'.rooms',
|
||||
'.rolemenus',
|
||||
'.member_admin',
|
||||
'.moderation',
|
||||
'.video_channels',
|
||||
'.meta',
|
||||
'.test',
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
10
src/modules/moderation/__init__.py
Normal file
10
src/modules/moderation/__init__.py
Normal 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))
|
||||
208
src/modules/moderation/cog.py
Normal file
208
src/modules/moderation/cog.py
Normal 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()
|
||||
110
src/modules/moderation/data.py
Normal file
110
src/modules/moderation/data.py
Normal 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()
|
||||
168
src/modules/moderation/settings.py
Normal file
168
src/modules/moderation/settings.py
Normal 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."
|
||||
))
|
||||
157
src/modules/moderation/settingui.py
Normal file
157
src/modules/moderation/settingui.py
Normal 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
|
||||
339
src/modules/moderation/ticket.py
Normal file
339
src/modules/moderation/ticket.py
Normal 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
|
||||
10
src/modules/video_channels/__init__.py
Normal file
10
src/modules/video_channels/__init__.py
Normal 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))
|
||||
575
src/modules/video_channels/cog.py
Normal file
575
src/modules/video_channels/cog.py
Normal 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()
|
||||
|
||||
7
src/modules/video_channels/data.py
Normal file
7
src/modules/video_channels/data.py
Normal 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')
|
||||
315
src/modules/video_channels/settings.py
Normal file
315
src/modules/video_channels/settings.py
Normal 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.")
|
||||
163
src/modules/video_channels/settingui.py
Normal file
163
src/modules/video_channels/settingui.py
Normal 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
|
||||
110
src/modules/video_channels/ticket.py
Normal file
110
src/modules/video_channels/ticket.py
Normal 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}")
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user