rewrite: New Video channels and moderation.
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user