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;
|
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');
|
INSERT INTO VersionHistory (version, author) VALUES (13, 'v12-v13 migration');
|
||||||
|
|
||||||
COMMIT;
|
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
|
return fmt
|
||||||
|
|
||||||
|
|
||||||
log_format = ('[%(green)%(asctime)-19s%(reset)][%(red)%(levelname)-8s%(reset)]' +
|
log_format = ('%(green)%(asctime)-19s%(reset)|%(red)%(levelname)-8s%(reset)|' +
|
||||||
'[%(cyan)%(app)-15s%(reset)]' +
|
'%(cyan)%(app)-15s%(reset)|' +
|
||||||
'[%(cyan)%(context)-24s%(reset)]' +
|
'%(cyan)%(context)-24s%(reset)|' +
|
||||||
'[%(cyan)%(actionstr)-22s%(reset)]' +
|
'%(cyan)%(actionstr)-22s%(reset)|' +
|
||||||
' %(bold)%(cyan)%(name)s:%(reset)' +
|
' %(bold)%(cyan)%(name)s:%(reset)' +
|
||||||
' %(white)%(message)s%(ctxstr)s%(reset)')
|
' %(white)%(message)s%(ctxstr)s%(reset)')
|
||||||
log_format = colour_escape(log_format)
|
log_format = colour_escape(log_format)
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ active = [
|
|||||||
'.rooms',
|
'.rooms',
|
||||||
'.rolemenus',
|
'.rolemenus',
|
||||||
'.member_admin',
|
'.member_admin',
|
||||||
|
'.moderation',
|
||||||
|
'.video_channels',
|
||||||
'.meta',
|
'.meta',
|
||||||
'.test',
|
'.test',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ from babel.settingui import LocaleDashboard
|
|||||||
from modules.schedule.ui.settingui import ScheduleDashboard
|
from modules.schedule.ui.settingui import ScheduleDashboard
|
||||||
from modules.statistics.settings import StatisticsDashboard
|
from modules.statistics.settings import StatisticsDashboard
|
||||||
from modules.member_admin.settingui import MemberAdminDashboard
|
from modules.member_admin.settingui import MemberAdminDashboard
|
||||||
|
from modules.moderation.settingui import ModerationDashboard
|
||||||
|
from modules.video_channels.settingui import VideoDashboard
|
||||||
|
|
||||||
|
|
||||||
from . import babel, logger
|
from . import babel, logger
|
||||||
@@ -33,6 +35,7 @@ class GuildDashboard(BasePager):
|
|||||||
"""
|
"""
|
||||||
pages = [
|
pages = [
|
||||||
(MemberAdminDashboard, LocaleDashboard, EconomyDashboard,),
|
(MemberAdminDashboard, LocaleDashboard, EconomyDashboard,),
|
||||||
|
(ModerationDashboard, VideoDashboard,),
|
||||||
(VoiceTrackerDashboard, TextTrackerDashboard, RankDashboard, StatisticsDashboard,),
|
(VoiceTrackerDashboard, TextTrackerDashboard, RankDashboard, StatisticsDashboard,),
|
||||||
(TasklistDashboard, RoomDashboard, TimerDashboard,),
|
(TasklistDashboard, RoomDashboard, TimerDashboard,),
|
||||||
(ScheduleDashboard,),
|
(ScheduleDashboard,),
|
||||||
@@ -138,17 +141,14 @@ class GuildDashboard(BasePager):
|
|||||||
menu = self.config_menu
|
menu = self.config_menu
|
||||||
menu.placeholder = t(_p(
|
menu.placeholder = t(_p(
|
||||||
'ui:dashboard|menu:config|placeholder',
|
'ui:dashboard|menu:config|placeholder',
|
||||||
"Expand Configuration Group"
|
"Open Configuration Panel"
|
||||||
))
|
))
|
||||||
|
|
||||||
options = []
|
options = []
|
||||||
for i, page in enumerate(self.pages):
|
for i, page in enumerate(self.pages):
|
||||||
for j, section in enumerate(page):
|
for j, section in enumerate(page):
|
||||||
option = SelectOption(
|
option = SelectOption(
|
||||||
label=t(section.section_name).format(
|
label=section(self.bot, self.guildid).option_name,
|
||||||
bot=self.bot,
|
|
||||||
commands=self.bot.core.mention_cache
|
|
||||||
),
|
|
||||||
value=str(i * 10 + j)
|
value=str(i * 10 + j)
|
||||||
)
|
)
|
||||||
options.append(option)
|
options.append(option)
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ class MemberAdminUI(ConfigUI):
|
|||||||
setting = self.get_instance(Settings.GreetingChannel)
|
setting = self.get_instance(Settings.GreetingChannel)
|
||||||
setting.value = selected.values[0] if selected.values else None
|
setting.value = selected.values[0] if selected.values else None
|
||||||
await setting.write()
|
await setting.write()
|
||||||
|
await selection.delete_original_response()
|
||||||
|
|
||||||
async def greetch_menu_refresh(self):
|
async def greetch_menu_refresh(self):
|
||||||
menu = self.greetch_menu
|
menu = self.greetch_menu
|
||||||
@@ -89,6 +90,7 @@ class MemberAdminUI(ConfigUI):
|
|||||||
@select(
|
@select(
|
||||||
cls=RoleSelect,
|
cls=RoleSelect,
|
||||||
placeholder="BOT_AUTOROLES_MENU_PLACEHOLDER",
|
placeholder="BOT_AUTOROLES_MENU_PLACEHOLDER",
|
||||||
|
min_values=0, max_values=25
|
||||||
)
|
)
|
||||||
async def bot_autoroles_menu(self, selection: discord.Interaction, selected: RoleSelect):
|
async def bot_autoroles_menu(self, selection: discord.Interaction, selected: RoleSelect):
|
||||||
"""
|
"""
|
||||||
@@ -254,5 +256,9 @@ class MemberAdminDashboard(DashboardSection):
|
|||||||
"dash:member_admin|title",
|
"dash:member_admin|title",
|
||||||
"Greetings and Initial Roles ({commands[configure welcome]})"
|
"Greetings and Initial Roles ({commands[configure welcome]})"
|
||||||
)
|
)
|
||||||
|
_option_name = _p(
|
||||||
|
"dash:member_admin|dropdown|placeholder",
|
||||||
|
"Greetings and Initial Roles Panel"
|
||||||
|
)
|
||||||
configui = MemberAdminUI
|
configui = MemberAdminUI
|
||||||
setting_classes = MemberAdminUI.setting_classes
|
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")
|
_accepts = _p('settype:role|accepts', "A role name or id")
|
||||||
|
|
||||||
_selector_placeholder = "Select a Role"
|
_selector_placeholder = "Select a Role"
|
||||||
|
_allow_object = True
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _get_guildid(cls, parent_id: int, **kwargs) -> int:
|
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)
|
guild = bot.get_guild(guildid)
|
||||||
if guild is not None:
|
if guild is not None:
|
||||||
role = guild.get_role(data)
|
role = guild.get_role(data)
|
||||||
if role is None:
|
if role is None and cls._allow_object:
|
||||||
role = discord.Object(id=data)
|
role = discord.Object(id=data)
|
||||||
return role
|
return role
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ class TaskMonitor(Generic[Taskid]):
|
|||||||
wake = self._taskmap[nextid] >= timestamp
|
wake = self._taskmap[nextid] >= timestamp
|
||||||
wake = wake or taskid == nextid
|
wake = wake or taskid == nextid
|
||||||
else:
|
else:
|
||||||
wake = False
|
wake = True
|
||||||
if taskid in self._taskmap:
|
if taskid in self._taskmap:
|
||||||
self._tasklist.remove(taskid)
|
self._tasklist.remove(taskid)
|
||||||
self._taskmap[taskid] = timestamp
|
self._taskmap[taskid] = timestamp
|
||||||
|
|||||||
@@ -281,6 +281,8 @@ class DashboardSection:
|
|||||||
setting_classes = []
|
setting_classes = []
|
||||||
configui = None
|
configui = None
|
||||||
|
|
||||||
|
_option_name = None
|
||||||
|
|
||||||
def __init__(self, bot: LionBot, guildid: int):
|
def __init__(self, bot: LionBot, guildid: int):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
self.guildid = guildid
|
self.guildid = guildid
|
||||||
@@ -289,6 +291,16 @@ class DashboardSection:
|
|||||||
# Populated in load()
|
# Populated in load()
|
||||||
self.instances = []
|
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):
|
async def load(self):
|
||||||
"""
|
"""
|
||||||
Initialise the contained settings.
|
Initialise the contained settings.
|
||||||
|
|||||||
Reference in New Issue
Block a user