rewrite: New Video channels and moderation.

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

View File

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

View File

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

View File

@@ -0,0 +1,110 @@
from enum import Enum
from data import Registry, Table, RowModel, RegisterEnum
from data.columns import (
Column,
Integer, String, Bool, Timestamp,
)
class TicketType(Enum):
"""
Schema
------
CREATE TYPE TicketType AS ENUM (
'NOTE',
'STUDY_BAN',
'MESSAGE_CENSOR',
'INVITE_CENSOR',
'WARNING'
);
"""
NOTE = 'NOTE',
STUDY_BAN = 'STUDY_BAN',
MESSAGE_CENSOR = 'MESSAGE_CENSOR',
INVITE_CENSOR = 'INVITE_CENSOR',
WARNING = 'WARNING',
class TicketState(Enum):
"""
Schema
------
CREATE TYPE TicketState AS ENUM (
'OPEN',
'EXPIRING',
'EXPIRED',
'PARDONED'
);
"""
OPEN = 'OPEN',
EXPIRING = 'EXPIRING',
EXPIRED = 'EXPIRED',
PARDONED = 'PARDONED',
class ModerationData(Registry):
_TicketType = RegisterEnum(TicketType, 'TicketType')
_TicketState = RegisterEnum(TicketState, 'TicketState')
class Ticket(RowModel):
"""
Schema
------
CREATE TABLE tickets(
ticketid SERIAL PRIMARY KEY,
guildid BIGINT NOT NULL,
targetid BIGINT NOT NULL,
ticket_type TicketType NOT NULL,
ticket_state TicketState NOT NULL DEFAULT 'OPEN',
moderator_id BIGINT NOT NULL,
log_msg_id BIGINT,
created_at TIMESTAMP DEFAULT (now() at time zone 'utc'),
auto BOOLEAN DEFAULT FALSE, -- Whether the ticket was automatically created
content TEXT, -- Main ticket content, usually contains the ticket reason
context TEXT, -- Optional flexible column only used by some TicketTypes
addendum TEXT, -- Optional extra text used for after-the-fact context information
duration INTEGER, -- Optional duration column, mostly used by automatic tickets
file_name TEXT, -- Optional file name to accompany the ticket
file_data BYTEA, -- Optional file data to accompany the ticket
expiry TIMESTAMPTZ, -- Time to automatically expire the ticket
pardoned_by BIGINT, -- Actorid who pardoned the ticket
pardoned_at TIMESTAMPTZ, -- Time when the ticket was pardoned
pardoned_reason TEXT -- Reason the ticket was pardoned
);
CREATE INDEX tickets_members_types ON tickets (guildid, targetid, ticket_type);
CREATE INDEX tickets_states ON tickets (ticket_state);
CREATE VIEW ticket_info AS
SELECT
*,
row_number() OVER (PARTITION BY guildid ORDER BY ticketid) AS guild_ticketid
FROM tickets
ORDER BY ticketid;
ALTER TABLE ticket_info ALTER ticket_state SET DEFAULT 'OPEN';
ALTER TABLE ticket_info ALTER created_at SET DEFAULT (now() at time zone 'utc');
ALTER TABLE ticket_info ALTER auto SET DEFAULT False;
"""
_tablename_ = 'ticket_info'
ticketid = Integer(primary=True)
guild_ticketid = Integer()
guildid = Integer()
targetid = Integer()
ticket_type: Column[TicketType] = Column()
ticket_state: Column[TicketState] = Column()
moderator_id = Integer()
log_msg_id = Integer()
auto = Bool()
content = String()
context = String()
addendum = String()
duration = Integer()
file_name = String()
file_data = String()
expiry = Timestamp()
pardoned_by = Integer()
pardoned_at = Integer()
pardoned_reason = String()
created_at = Timestamp()

View File

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

View File

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

View File

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