feat(moderation): Implement notes and warnings.
This commit is contained in:
@@ -5,22 +5,26 @@ import asyncio
|
|||||||
import discord
|
import discord
|
||||||
from discord.ext import commands as cmds
|
from discord.ext import commands as cmds
|
||||||
from discord import app_commands as appcmds
|
from discord import app_commands as appcmds
|
||||||
|
from discord.ui.text_input import TextInput, TextStyle
|
||||||
|
|
||||||
from meta import LionCog, LionBot, LionContext
|
from meta import LionCog, LionBot, LionContext
|
||||||
|
from meta.errors import SafeCancellation
|
||||||
from meta.logger import log_wrap
|
from meta.logger import log_wrap
|
||||||
from meta.sharding import THIS_SHARD
|
from meta.sharding import THIS_SHARD
|
||||||
from core.data import CoreData
|
from core.data import CoreData
|
||||||
from utils.lib import utc_now
|
from utils.lib import utc_now
|
||||||
|
from utils.ui import input
|
||||||
|
|
||||||
from wards import low_management_ward, high_management_ward, equippable_role
|
from wards import low_management_ward, high_management_ward, equippable_role, moderator_ward
|
||||||
|
|
||||||
from . import babel, logger
|
from . import babel, logger
|
||||||
from .data import ModerationData, TicketType, TicketState
|
from .data import ModerationData, TicketType, TicketState
|
||||||
from .settings import ModerationSettings
|
from .settings import ModerationSettings
|
||||||
from .settingui import ModerationSettingUI
|
from .settingui import ModerationSettingUI
|
||||||
from .ticket import Ticket
|
from .ticket import Ticket
|
||||||
|
from .tickets import NoteTicket, WarnTicket
|
||||||
|
|
||||||
_p = babel._p
|
_p, _np = babel._p, babel._np
|
||||||
|
|
||||||
|
|
||||||
class ModerationCog(LionCog):
|
class ModerationCog(LionCog):
|
||||||
@@ -125,6 +129,228 @@ class ModerationCog(LionCog):
|
|||||||
...
|
...
|
||||||
|
|
||||||
# ----- Commands -----
|
# ----- Commands -----
|
||||||
|
# modnote command
|
||||||
|
@cmds.hybrid_command(
|
||||||
|
name=_p('cmd:modnote', "modnote"),
|
||||||
|
description=_p(
|
||||||
|
'cmd:modnote|desc',
|
||||||
|
"Add a note to the target member's moderation record."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
@appcmds.rename(
|
||||||
|
target=_p('cmd:modnote|param:target', "target"),
|
||||||
|
note=_p('cmd:modnote|param:note', "note"),
|
||||||
|
)
|
||||||
|
@appcmds.describe(
|
||||||
|
target=_p(
|
||||||
|
'cmd:modnote|param:target|desc',
|
||||||
|
"Target member or user to add a note to."
|
||||||
|
),
|
||||||
|
note=_p(
|
||||||
|
'cmd:modnote|param:note|desc',
|
||||||
|
"Contents of the note."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@appcmds.default_permissions(manage_guild=True)
|
||||||
|
@appcmds.guild_only
|
||||||
|
@moderator_ward
|
||||||
|
async def cmd_modnote(self, ctx: LionContext,
|
||||||
|
target: discord.Member | discord.User,
|
||||||
|
note: Optional[appcmds.Range[str, 1, 1024]] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create a NoteTicket on the given target.
|
||||||
|
|
||||||
|
If `note` is not given, prompts for the note content via modal.
|
||||||
|
"""
|
||||||
|
if not ctx.guild:
|
||||||
|
return
|
||||||
|
if not ctx.interaction:
|
||||||
|
return
|
||||||
|
t = self.bot.translator.t
|
||||||
|
|
||||||
|
if note is None:
|
||||||
|
# Prompt for note via modal
|
||||||
|
modal_title = t(_p(
|
||||||
|
'cmd:modnote|modal:enter_note|title',
|
||||||
|
"Moderation Note"
|
||||||
|
))
|
||||||
|
input_field = TextInput(
|
||||||
|
label=t(_p(
|
||||||
|
'cmd:modnote|modal:enter_note|field|label',
|
||||||
|
"Note Content",
|
||||||
|
)),
|
||||||
|
style=TextStyle.long,
|
||||||
|
min_length=1,
|
||||||
|
max_length=1024,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
interaction, note = await input(
|
||||||
|
ctx.interaction, modal_title,
|
||||||
|
field=input_field,
|
||||||
|
timeout=300
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
# Moderator did not fill in the modal in time
|
||||||
|
# Just leave quietly
|
||||||
|
raise SafeCancellation
|
||||||
|
else:
|
||||||
|
interaction = ctx.interaction
|
||||||
|
|
||||||
|
await interaction.response.defer(thinking=True, ephemeral=True)
|
||||||
|
|
||||||
|
# Create NoteTicket
|
||||||
|
ticket = await NoteTicket.create(
|
||||||
|
bot=self.bot,
|
||||||
|
guildid=ctx.guild.id, userid=target.id,
|
||||||
|
moderatorid=ctx.author.id, content=note, expiry=None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Write confirmation with ticket number and link to ticket if relevant
|
||||||
|
embed = discord.Embed(
|
||||||
|
colour=discord.Colour.orange(),
|
||||||
|
description=t(_p(
|
||||||
|
'cmd:modnote|embed:success|desc',
|
||||||
|
"Moderation note created as [Ticket #{ticket}]({jump_link})"
|
||||||
|
)).format(
|
||||||
|
ticket=ticket.data.guild_ticketid,
|
||||||
|
jump_link=ticket.jump_url or ctx.message.jump_url
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await interaction.edit_original_response(embed=embed)
|
||||||
|
|
||||||
|
# Warning Ticket Command
|
||||||
|
@cmds.hybrid_command(
|
||||||
|
name=_p('cmd:warning', "warning"),
|
||||||
|
description=_p(
|
||||||
|
'cmd:warning|desc',
|
||||||
|
"Warn a member for a misdemeanour, and add it to their moderation record."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
@appcmds.rename(
|
||||||
|
target=_p('cmd:warning|param:target', "target"),
|
||||||
|
reason=_p('cmd:warning|param:reason', "reason"),
|
||||||
|
)
|
||||||
|
@appcmds.describe(
|
||||||
|
target=_p(
|
||||||
|
'cmd:warning|param:target|desc',
|
||||||
|
"Target member to warn."
|
||||||
|
),
|
||||||
|
reason=_p(
|
||||||
|
'cmd:warning|param:reason|desc',
|
||||||
|
"The reason why you are warning this member."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@appcmds.default_permissions(manage_guild=True)
|
||||||
|
@appcmds.guild_only
|
||||||
|
@moderator_ward
|
||||||
|
async def cmd_warning(self, ctx: LionContext,
|
||||||
|
target: discord.Member,
|
||||||
|
reason: Optional[appcmds.Range[str, 0, 1024]] = None,
|
||||||
|
):
|
||||||
|
if not ctx.guild:
|
||||||
|
return
|
||||||
|
if not ctx.interaction:
|
||||||
|
return
|
||||||
|
t = self.bot.translator.t
|
||||||
|
|
||||||
|
# Prompt for warning reason if not given
|
||||||
|
if reason is None:
|
||||||
|
modal_title = t(_p(
|
||||||
|
'cmd:warning|modal:reason|title',
|
||||||
|
"Moderation Warning"
|
||||||
|
))
|
||||||
|
input_field = TextInput(
|
||||||
|
label=t(_p(
|
||||||
|
'cmd:warning|modal:reason|field|label',
|
||||||
|
"Reason for the warning (visible to user)."
|
||||||
|
)),
|
||||||
|
style=TextStyle.long,
|
||||||
|
min_length=0,
|
||||||
|
max_length=1024,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
interaction, note = await input(
|
||||||
|
ctx.interaction, modal_title,
|
||||||
|
field=input_field,
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
raise SafeCancellation
|
||||||
|
else:
|
||||||
|
interaction = ctx.interaction
|
||||||
|
|
||||||
|
await interaction.response.defer(thinking=True, ephemeral=False)
|
||||||
|
|
||||||
|
# Create WarnTicket
|
||||||
|
ticket = await WarnTicket.create(
|
||||||
|
bot=self.bot,
|
||||||
|
guildid=ctx.guild.id, userid=target.id,
|
||||||
|
moderatorid=ctx.author.id, content=reason
|
||||||
|
)
|
||||||
|
|
||||||
|
# Post to user or moderation notify channel
|
||||||
|
alert_embed = discord.Embed(
|
||||||
|
colour=discord.Colour.dark_red(),
|
||||||
|
title=t(_p(
|
||||||
|
'cmd:warning|embed:user_alert|title',
|
||||||
|
"You have received a warning!"
|
||||||
|
)),
|
||||||
|
description=reason,
|
||||||
|
)
|
||||||
|
alert_embed.add_field(
|
||||||
|
name=t(_p(
|
||||||
|
'cmd:warning|embed:user_alert|field:note|name',
|
||||||
|
"Note"
|
||||||
|
)),
|
||||||
|
value=t(_p(
|
||||||
|
'cmd:warning|embed:user_alert|field:note|value',
|
||||||
|
"*Warnings appear in your moderation history."
|
||||||
|
" Continuing failure to comply with server rules and moderator"
|
||||||
|
" directions may result in more severe action."
|
||||||
|
))
|
||||||
|
)
|
||||||
|
alert_embed.set_footer(
|
||||||
|
icon_url=ctx.guild.icon,
|
||||||
|
text=ctx.guild.name,
|
||||||
|
)
|
||||||
|
alert = await self.send_alert(target, embed=alert_embed)
|
||||||
|
|
||||||
|
# Ack the ticket creation, including alert status and warning count
|
||||||
|
|
||||||
|
warning_count = await ticket.count_warnings_for(
|
||||||
|
self.bot, ctx.guild.id, target.id
|
||||||
|
)
|
||||||
|
count_line = t(_np(
|
||||||
|
'cmd:warning|embed:success|line:count',
|
||||||
|
"This their first warning.",
|
||||||
|
"They have recieved **`{count}`** warnings.",
|
||||||
|
warning_count
|
||||||
|
)).format(count=warning_count)
|
||||||
|
|
||||||
|
embed = discord.Embed(
|
||||||
|
colour=discord.Colour.orange(),
|
||||||
|
description=t(_p(
|
||||||
|
'cmd:warning|embed:success|desc',
|
||||||
|
"[Ticket #{ticket}]({jump_link}) {user} has been warned."
|
||||||
|
)).format(
|
||||||
|
ticket=ticket.data.guild_ticketid,
|
||||||
|
jump_link=ticket.jump_url or ctx.message.jump_url,
|
||||||
|
user=target.mention,
|
||||||
|
) + '\n' + count_line
|
||||||
|
)
|
||||||
|
if alert is None:
|
||||||
|
embed.add_field(
|
||||||
|
name=t(_p(
|
||||||
|
'cmd:warning|embed:success|field:no_alert|name',
|
||||||
|
"Note"
|
||||||
|
)),
|
||||||
|
value=t(_p(
|
||||||
|
'cmd:warning|embed:success|field:no_alert|value',
|
||||||
|
"*Could not deliver warning to the target.*"
|
||||||
|
))
|
||||||
|
)
|
||||||
|
await interaction.edit_original_response(embed=embed)
|
||||||
|
|
||||||
# ----- Configuration -----
|
# ----- Configuration -----
|
||||||
@LionCog.placeholder_group
|
@LionCog.placeholder_group
|
||||||
|
|||||||
@@ -99,11 +99,11 @@ class Ticket:
|
|||||||
return tickets
|
return tickets
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def guild(self):
|
def guild(self) -> Optional[discord.Guild]:
|
||||||
return self.bot.get_guild(self.data.guildid)
|
return self.bot.get_guild(self.data.guildid)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def target(self):
|
def target(self) -> Optional[discord.Member]:
|
||||||
guild = self.guild
|
guild = self.guild
|
||||||
if guild:
|
if guild:
|
||||||
return guild.get_member(self.data.targetid)
|
return guild.get_member(self.data.targetid)
|
||||||
@@ -111,7 +111,7 @@ class Ticket:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def type(self):
|
def type(self) -> TicketType:
|
||||||
return self.data.ticket_type
|
return self.data.ticket_type
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
2
src/modules/moderation/tickets/__init__.py
Normal file
2
src/modules/moderation/tickets/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
from .note import NoteTicket
|
||||||
|
from .warning import WarnTicket
|
||||||
48
src/modules/moderation/tickets/note.py
Normal file
48
src/modules/moderation/tickets/note.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
from typing import TYPE_CHECKING
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from meta import LionBot
|
||||||
|
from utils.lib import utc_now
|
||||||
|
|
||||||
|
from ..ticket import Ticket, ticket_factory
|
||||||
|
from ..data import TicketType, TicketState, ModerationData
|
||||||
|
from .. import logger, babel
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ..cog import ModerationCog
|
||||||
|
|
||||||
|
_p = babel._p
|
||||||
|
|
||||||
|
|
||||||
|
@ticket_factory(TicketType.NOTE)
|
||||||
|
class NoteTicket(Ticket):
|
||||||
|
__slots__ = ()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def create(
|
||||||
|
cls, bot: LionBot, guildid: int, userid: int,
|
||||||
|
moderatorid: int, content: str, expiry=None,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
modcog: 'ModerationCog' = bot.get_cog('ModerationCog')
|
||||||
|
ticket_data = await modcog.data.Ticket.create(
|
||||||
|
guildid=guildid,
|
||||||
|
targetid=userid,
|
||||||
|
ticket_type=TicketType.NOTE,
|
||||||
|
ticket_state=TicketState.OPEN,
|
||||||
|
moderator_id=moderatorid,
|
||||||
|
content=content,
|
||||||
|
expiry=expiry,
|
||||||
|
created_at=utc_now().replace(tzinfo=None),
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
lguild = await bot.core.lions.fetch_guild(guildid)
|
||||||
|
new_ticket = cls(lguild, ticket_data)
|
||||||
|
await new_ticket.post()
|
||||||
|
|
||||||
|
if expiry:
|
||||||
|
cls.expiring.schedule_task(ticket_data.ticketid, expiry.timestamp())
|
||||||
|
|
||||||
|
return new_ticket
|
||||||
66
src/modules/moderation/tickets/warning.py
Normal file
66
src/modules/moderation/tickets/warning.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from meta import LionBot
|
||||||
|
from utils.lib import utc_now
|
||||||
|
|
||||||
|
from ..ticket import Ticket, ticket_factory
|
||||||
|
from ..data import TicketType, TicketState, ModerationData
|
||||||
|
from .. import logger, babel
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ..cog import ModerationCog
|
||||||
|
|
||||||
|
_p = babel._p
|
||||||
|
|
||||||
|
|
||||||
|
@ticket_factory(TicketType.WARNING)
|
||||||
|
class WarnTicket(Ticket):
|
||||||
|
__slots__ = ()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def create(
|
||||||
|
cls, bot: LionBot, guildid: int, userid: int,
|
||||||
|
moderatorid: int, content: Optional[str], expiry=None,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
modcog: 'ModerationCog' = bot.get_cog('ModerationCog')
|
||||||
|
ticket_data = await modcog.data.Ticket.create(
|
||||||
|
guildid=guildid,
|
||||||
|
targetid=userid,
|
||||||
|
ticket_type=TicketType.WARNING,
|
||||||
|
ticket_state=TicketState.OPEN,
|
||||||
|
moderator_id=moderatorid,
|
||||||
|
content=content,
|
||||||
|
expiry=expiry,
|
||||||
|
created_at=utc_now().replace(tzinfo=None),
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
lguild = await bot.core.lions.fetch_guild(guildid)
|
||||||
|
new_ticket = cls(lguild, ticket_data)
|
||||||
|
await new_ticket.post()
|
||||||
|
|
||||||
|
if expiry:
|
||||||
|
cls.expiring.schedule_task(ticket_data.ticketid, expiry.timestamp())
|
||||||
|
|
||||||
|
return new_ticket
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def count_warnings_for(
|
||||||
|
cls, bot: LionBot, guildid: int, userid: int, **kwargs
|
||||||
|
):
|
||||||
|
modcog: 'ModerationCog' = bot.get_cog('ModerationCog')
|
||||||
|
Ticket = modcog.data.Ticket
|
||||||
|
record = await Ticket.table.select_one_where(
|
||||||
|
(Ticket.ticket_state != TicketState.PARDONED),
|
||||||
|
guildid=guildid,
|
||||||
|
targetid=userid,
|
||||||
|
ticket_type=TicketType.WARNING,
|
||||||
|
**kwargs
|
||||||
|
).select(ticket_count='COUNT(*)').with_no_adapter()
|
||||||
|
return (record[0]['ticket_count'] or 0) if record else 0
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user