rewrite: Initial rewrite skeleton.

Remove modules that will no longer be required.
Move pending modules to pending-rewrite folders.
This commit is contained in:
2022-09-17 17:06:13 +10:00
parent a7f7dd6e7b
commit a5147323b5
162 changed files with 1 additions and 866 deletions

View File

@@ -0,0 +1,486 @@
import asyncio
import logging
import traceback
import datetime
import discord
from meta import client
from data.conditions import THIS_SHARD
from settings import GuildSettings
from utils.lib import FieldEnum, strfdelta, utc_now
from .. import data
from ..module import module
class TicketType(FieldEnum):
"""
The possible ticket types.
"""
NOTE = 'NOTE', 'Note'
WARNING = 'WARNING', 'Warning'
STUDY_BAN = 'STUDY_BAN', 'Study Ban'
MESAGE_CENSOR = 'MESSAGE_CENSOR', 'Message Censor'
INVITE_CENSOR = 'INVITE_CENSOR', 'Invite Censor'
class TicketState(FieldEnum):
"""
The possible ticket states.
"""
OPEN = 'OPEN', "Active"
EXPIRING = 'EXPIRING', "Active"
EXPIRED = 'EXPIRED', "Expired"
PARDONED = 'PARDONED', "Pardoned"
REVERTED = 'REVERTED', "Reverted"
class Ticket:
"""
Abstract base class representing a Ticketed moderation action.
"""
# Type of event the class represents
_ticket_type = None # type: TicketType
_ticket_types = {} # Map: TicketType -> Ticket subclass
_expiry_tasks = {} # Map: ticketid -> expiry Task
def __init__(self, ticketid, *args, **kwargs):
self.ticketid = ticketid
@classmethod
async def create(cls, *args, **kwargs):
"""
Method used to create a new ticket of the current type.
Should add a row to the ticket table, post the ticket, and return the Ticket.
"""
raise NotImplementedError
@property
def data(self):
"""
Ticket row.
This will usually be a row of `ticket_info`.
"""
return data.ticket_info.fetch(self.ticketid)
@property
def guild(self):
return client.get_guild(self.data.guildid)
@property
def target(self):
guild = self.guild
return guild.get_member(self.data.targetid) if guild else None
@property
def msg_args(self):
"""
Ticket message posted in the moderation log.
"""
args = {}
# Build embed
info = self.data
member = self.target
name = str(member) if member else str(info.targetid)
if info.auto:
title_fmt = "Ticket #{} | {} | {}[Auto] | {}"
else:
title_fmt = "Ticket #{} | {} | {} | {}"
title = title_fmt.format(
info.guild_ticketid,
TicketState(info.ticket_state).desc,
TicketType(info.ticket_type).desc,
name
)
embed = discord.Embed(
title=title,
description=info.content,
timestamp=info.created_at
)
embed.add_field(
name="Target",
value="<@{}>".format(info.targetid)
)
if not info.auto:
embed.add_field(
name="Moderator",
value="<@{}>".format(info.moderator_id)
)
# if info.duration:
# value = "`{}` {}".format(
# strfdelta(datetime.timedelta(seconds=info.duration)),
# "(Expiry <t:{:.0f}>)".format(info.expiry.timestamp()) if info.expiry else ""
# )
# embed.add_field(
# name="Duration",
# value=value
# )
if info.expiry:
if info.ticket_state == TicketState.EXPIRING:
embed.add_field(
name="Expires at",
value="<t:{:.0f}>\n(Duration: `{}`)".format(
info.expiry.timestamp(),
strfdelta(datetime.timedelta(seconds=info.duration))
)
)
elif info.ticket_state == TicketState.EXPIRED:
embed.add_field(
name="Expired",
value="<t:{:.0f}>".format(
info.expiry.timestamp(),
)
)
else:
embed.add_field(
name="Expiry",
value="<t:{:.0f}>".format(
info.expiry.timestamp()
)
)
if info.context:
embed.add_field(
name="Context",
value=info.context,
inline=False
)
if info.addendum:
embed.add_field(
name="Notes",
value=info.addendum,
inline=False
)
if self.state == TicketState.PARDONED:
embed.add_field(
name="Pardoned",
value=(
"Pardoned by <@{}> at <t:{:.0f}>.\n{}"
).format(
info.pardoned_by,
info.pardoned_at.timestamp(),
info.pardoned_reason or ""
),
inline=False
)
embed.set_footer(text="ID: {}".format(info.targetid))
args['embed'] = embed
# Add file
if info.file_name:
args['file'] = discord.File(info.file_data, info.file_name)
return args
@property
def link(self):
"""
The link to the ticket in the moderation log.
"""
info = self.data
modlog = GuildSettings(info.guildid).mod_log.data
return 'https://discord.com/channels/{}/{}/{}'.format(
info.guildid,
modlog,
info.log_msg_id
)
@property
def state(self):
return TicketState(self.data.ticket_state)
@property
def type(self):
return TicketType(self.data.ticket_type)
async def update(self, **kwargs):
"""
Update ticket fields.
"""
fields = (
'targetid', 'moderator_id', 'auto', 'log_msg_id',
'content', 'expiry', 'ticket_state',
'context', 'addendum', 'duration', 'file_name', 'file_data',
'pardoned_by', 'pardoned_at', 'pardoned_reason',
)
params = {field: kwargs[field] for field in fields if field in kwargs}
if params:
data.ticket_info.update_where(params, ticketid=self.ticketid)
await self.update_expiry()
await self.post()
async def post(self):
"""
Post or update the ticket in the moderation log.
Also updates the saved message id.
"""
info = self.data
modlog = GuildSettings(info.guildid).mod_log.value
if not modlog:
return
resend = True
try:
if info.log_msg_id:
# Try to fetch the message
message = await modlog.fetch_message(info.log_msg_id)
if message:
if message.author.id == client.user.id:
# TODO: Handle file edit
await message.edit(embed=self.msg_args['embed'])
resend = False
else:
try:
await message.delete()
except discord.HTTPException:
pass
if resend:
message = await modlog.send(**self.msg_args)
self.data.log_msg_id = message.id
except discord.HTTPException:
client.log(
"Cannot post ticket (tid: {}) due to discord exception or issue.".format(self.ticketid)
)
except Exception:
# This should never happen in normal operation
client.log(
"Error while posting ticket (tid:{})! "
"Exception traceback follows.\n{}".format(
self.ticketid,
traceback.format_exc()
),
context="TICKETS",
level=logging.ERROR
)
@classmethod
def load_expiring(cls):
"""
Load and schedule all expiring tickets.
"""
# TODO: Consider changing this to a flat timestamp system, to avoid storing lots of coroutines.
# TODO: Consider only scheduling the expiries in the next day, and updating this once per day.
# TODO: Only fetch tickets from guilds we are in.
# Cancel existing expiry tasks
for task in cls._expiry_tasks.values():
if not task.done():
task.cancel()
# Get all expiring tickets
expiring_rows = data.tickets.select_where(
ticket_state=TicketState.EXPIRING,
guildid=THIS_SHARD
)
# Create new expiry tasks
now = utc_now()
cls._expiry_tasks = {
row['ticketid']: asyncio.create_task(
cls._schedule_expiry_for(
row['ticketid'],
(row['expiry'] - now).total_seconds()
)
) for row in expiring_rows
}
# Log
client.log(
"Loaded {} expiring tickets.".format(len(cls._expiry_tasks)),
context="TICKET_LOADER",
)
@classmethod
async def _schedule_expiry_for(cls, ticketid, delay):
"""
Schedule expiry for a given ticketid
"""
try:
await asyncio.sleep(delay)
ticket = Ticket.fetch(ticketid)
if ticket:
await asyncio.shield(ticket._expire())
except asyncio.CancelledError:
return
def update_expiry(self):
# Cancel any existing expiry task
task = self._expiry_tasks.pop(self.ticketid, None)
if task and not task.done():
task.cancel()
# Schedule a new expiry task, if applicable
if self.data.ticket_state == TicketState.EXPIRING:
self._expiry_tasks[self.ticketid] = asyncio.create_task(
self._schedule_expiry_for(
self.ticketid,
(self.data.expiry - utc_now()).total_seconds()
)
)
async def cancel_expiry(self):
"""
Cancel ticket expiry.
In particular, may be used if another ticket overrides `self`.
Sets the ticket state to `OPEN`, so that it no longer expires.
"""
if self.state == TicketState.EXPIRING:
# Update the ticket state
self.data.ticket_state = TicketState.OPEN
# Remove from expiry tsks
self.update_expiry()
# Repost
await self.post()
async def _revert(self, reason=None):
"""
Method used to revert the ticket action, e.g. unban or remove mute role.
Generally called by `pardon` and `_expire`.
May be overriden by the Ticket type, if they implement any revert logic.
Is a no-op by default.
"""
return
async def _expire(self):
"""
Method to automatically expire a ticket.
May be overriden by the Ticket type for more complex expiry logic.
Must set `data.ticket_state` to `EXPIRED` if applicable.
"""
if self.state == TicketState.EXPIRING:
client.log(
"Automatically expiring ticket (tid:{}).".format(self.ticketid),
context="TICKETS"
)
try:
await self._revert(reason="Automatic Expiry")
except Exception:
# This should never happen in normal operation
client.log(
"Error while expiring ticket (tid:{})! "
"Exception traceback follows.\n{}".format(
self.ticketid,
traceback.format_exc()
),
context="TICKETS",
level=logging.ERROR
)
# Update state
self.data.ticket_state = TicketState.EXPIRED
# Update log message
await self.post()
# Post a note to the modlog
modlog = GuildSettings(self.data.guildid).mod_log.value
if modlog:
try:
await modlog.send(
embed=discord.Embed(
colour=discord.Colour.orange(),
description="[Ticket #{}]({}) expired!".format(self.data.guild_ticketid, self.link)
)
)
except discord.HTTPException:
pass
async def pardon(self, moderator, reason, timestamp=None):
"""
Pardon process for the ticket.
May be overidden by the Ticket type for more complex pardon logic.
Must set `data.ticket_state` to `PARDONED` if applicable.
"""
if self.state != TicketState.PARDONED:
if self.state in (TicketState.OPEN, TicketState.EXPIRING):
try:
await self._revert(reason="Pardoned by {}".format(moderator.id))
except Exception:
# This should never happen in normal operation
client.log(
"Error while pardoning ticket (tid:{})! "
"Exception traceback follows.\n{}".format(
self.ticketid,
traceback.format_exc()
),
context="TICKETS",
level=logging.ERROR
)
# Update state
with self.data.batch_update():
self.data.ticket_state = TicketState.PARDONED
self.data.pardoned_at = utc_now()
self.data.pardoned_by = moderator.id
self.data.pardoned_reason = reason
# Update (i.e. remove) expiry
self.update_expiry()
# Update log message
await self.post()
@classmethod
def fetch_tickets(cls, *ticketids, **kwargs):
"""
Fetch tickets matching the given criteria (passed transparently to `select_where`).
Positional arguments are treated as `ticketids`, which are not supported in keyword arguments.
"""
if ticketids:
kwargs['ticketid'] = ticketids
# Set the ticket type to the class type if not specified
if cls._ticket_type and 'ticket_type' not in kwargs:
kwargs['ticket_type'] = cls._ticket_type
# This is actually mainly for caching, since we don't pass the data to the initialiser
rows = data.ticket_info.fetch_rows_where(
**kwargs
)
return [
cls._ticket_types[TicketType(row.ticket_type)](row.ticketid)
for row in rows
]
@classmethod
def fetch(cls, ticketid):
"""
Return the Ticket with the given id, if found, or `None` otherwise.
"""
tickets = cls.fetch_tickets(ticketid)
return tickets[0] if tickets else None
@classmethod
def register_ticket_type(cls, ticket_cls):
"""
Decorator to register a new Ticket subclass as a ticket type.
"""
cls._ticket_types[ticket_cls._ticket_type] = ticket_cls
return ticket_cls
@module.launch_task
async def load_expiring_tickets(client):
Ticket.load_expiring()

View File

@@ -0,0 +1,4 @@
from .Ticket import Ticket, TicketType, TicketState
from .studybans import StudyBanTicket
from .notes import NoteTicket
from .warns import WarnTicket

View File

@@ -0,0 +1,112 @@
"""
Note ticket implementation.
Guild moderators can add a note about a user, visible in their moderation history.
Notes appear in the moderation log and the user's ticket history, like any other ticket.
This module implements the Note TicketType and the `note` moderation command.
"""
from cmdClient.lib import ResponseTimedOut
from wards import guild_moderator
from ..module import module
from ..data import tickets
from .Ticket import Ticket, TicketType, TicketState
@Ticket.register_ticket_type
class NoteTicket(Ticket):
_ticket_type = TicketType.NOTE
@classmethod
async def create(cls, guildid, targetid, moderatorid, content, **kwargs):
"""
Create a new Note on a target.
`kwargs` are passed transparently to the table insert method.
"""
ticket_row = tickets.insert(
guildid=guildid,
targetid=targetid,
ticket_type=cls._ticket_type,
ticket_state=TicketState.OPEN,
moderator_id=moderatorid,
auto=False,
content=content,
**kwargs
)
# Create the note ticket
ticket = cls(ticket_row['ticketid'])
# Post the ticket and return
await ticket.post()
return ticket
@module.cmd(
"note",
group="Moderation",
desc="Add a Note to a member's record."
)
@guild_moderator()
async def cmd_note(ctx):
"""
Usage``:
{prefix}note @target
{prefix}note @target <content>
Description:
Add a note to the target's moderation record.
The note will appear in the moderation log and in the `tickets` command.
The `target` must be specificed by mention or user id.
If the `content` is not given, it will be prompted for.
Example:
{prefix}note {ctx.author.mention} Seen reading the `note` documentation.
"""
if not ctx.args:
return await ctx.error_reply(
"**Usage:** `{}note @target <content>`.".format(ctx.best_prefix)
)
# Extract the target. We don't require them to be in the server
splits = ctx.args.split(maxsplit=1)
target_str = splits[0].strip('<@!&> ')
if not target_str.isdigit():
return await ctx.error_reply(
"**Usage:** `{}note @target <content>`.\n"
"`target` must be provided by mention or userid.".format(ctx.best_prefix)
)
targetid = int(target_str)
# Extract or prompt for the content
if len(splits) != 2:
try:
content = await ctx.input("What note would you like to add?", timeout=300)
except ResponseTimedOut:
raise ResponseTimedOut("Prompt timed out, no note was created.")
else:
content = splits[1].strip()
# Create the note ticket
ticket = await NoteTicket.create(
ctx.guild.id,
targetid,
ctx.author.id,
content
)
if ticket.data.log_msg_id:
await ctx.embed_reply(
"Note on <@{}> created as [Ticket #{}]({}).".format(
targetid,
ticket.data.guild_ticketid,
ticket.link
)
)
else:
await ctx.embed_reply(
"Note on <@{}> created as Ticket #{}.".format(targetid, ticket.data.guild_ticketid)
)

View File

@@ -0,0 +1,126 @@
import datetime
import discord
from meta import client
from utils.lib import utc_now
from settings import GuildSettings
from data import NOT
from .. import data
from .Ticket import Ticket, TicketType, TicketState
@Ticket.register_ticket_type
class StudyBanTicket(Ticket):
_ticket_type = TicketType.STUDY_BAN
@classmethod
async def create(cls, guildid, targetid, moderatorid, reason, expiry=None, **kwargs):
"""
Create a new study ban ticket.
"""
# First create the ticket itself
ticket_row = data.tickets.insert(
guildid=guildid,
targetid=targetid,
ticket_type=cls._ticket_type,
ticket_state=TicketState.EXPIRING if expiry else TicketState.OPEN,
moderator_id=moderatorid,
auto=(moderatorid == client.user.id),
content=reason,
expiry=expiry,
**kwargs
)
# Create the Ticket
ticket = cls(ticket_row['ticketid'])
# Schedule ticket expiry, if applicable
if expiry:
ticket.update_expiry()
# Cancel any existing studyban expiry for this member
tickets = cls.fetch_tickets(
guildid=guildid,
ticketid=NOT(ticket_row['ticketid']),
targetid=targetid,
ticket_state=TicketState.EXPIRING
)
for ticket in tickets:
await ticket.cancel_expiry()
# Post the ticket
await ticket.post()
# Return the ticket
return ticket
async def _revert(self, reason=None):
"""
Revert the studyban by removing the role.
"""
guild_settings = GuildSettings(self.data.guildid)
role = guild_settings.studyban_role.value
target = self.target
if target and role:
try:
await target.remove_roles(
role,
reason="Reverting StudyBan: {}".format(reason)
)
except discord.HTTPException:
# TODO: Error log?
...
@classmethod
async def autoban(cls, guild, target, reason, **kwargs):
"""
Convenience method to automatically studyban a member, for the configured duration.
If the role is set, this will create and return a `StudyBanTicket` regardless of whether the
studyban was successful.
If the role is not set, or the ticket cannot be created, this will return `None`.
"""
# Get the studyban role, fail if there isn't one set, or the role doesn't exist
guild_settings = GuildSettings(guild.id)
role = guild_settings.studyban_role.value
if not role:
return None
# Attempt to add the role, record failure
try:
await target.add_roles(role, reason="Applying StudyBan: {}".format(reason[:400]))
except discord.HTTPException:
role_failed = True
else:
role_failed = False
# Calculate the applicable automatic duration and expiry
# First count the existing non-pardoned studybans for this target
studyban_count = data.tickets.select_one_where(
guildid=guild.id,
targetid=target.id,
ticket_type=cls._ticket_type,
ticket_state=NOT(TicketState.PARDONED),
select_columns=('COUNT(*)',)
)[0]
studyban_count = int(studyban_count)
# Then read the guild setting to find the applicable duration
studyban_durations = guild_settings.studyban_durations.value
if studyban_count < len(studyban_durations):
duration = studyban_durations[studyban_count]
expiry = utc_now() + datetime.timedelta(seconds=duration)
else:
duration = None
expiry = None
# Create the ticket and return
if role_failed:
kwargs['addendum'] = '\n'.join((
kwargs.get('addendum', ''),
"Could not add the studyban role! Please add the role manually and check my permissions."
))
return await cls.create(
guild.id, target.id, client.user.id, reason, duration=duration, expiry=expiry, **kwargs
)

View File

@@ -0,0 +1,153 @@
"""
Warn ticket implementation.
Guild moderators can officially warn a user via command.
This DMs the users with the warning.
"""
import datetime
import discord
from cmdClient.lib import ResponseTimedOut
from wards import guild_moderator
from ..module import module
from ..data import tickets
from .Ticket import Ticket, TicketType, TicketState
@Ticket.register_ticket_type
class WarnTicket(Ticket):
_ticket_type = TicketType.WARNING
@classmethod
async def create(cls, guildid, targetid, moderatorid, content, **kwargs):
"""
Create a new Warning for the target.
`kwargs` are passed transparently to the table insert method.
"""
ticket_row = tickets.insert(
guildid=guildid,
targetid=targetid,
ticket_type=cls._ticket_type,
ticket_state=TicketState.OPEN,
moderator_id=moderatorid,
content=content,
**kwargs
)
# Create the note ticket
ticket = cls(ticket_row['ticketid'])
# Post the ticket and return
await ticket.post()
return ticket
async def _revert(*args, **kwargs):
# Warnings don't have a revert process
pass
@module.cmd(
"warn",
group="Moderation",
desc="Officially warn a user for a misbehaviour."
)
@guild_moderator()
async def cmd_warn(ctx):
"""
Usage``:
{prefix}warn @target
{prefix}warn @target <reason>
Description:
The `target` must be specificed by mention or user id.
If the `reason` is not given, it will be prompted for.
Example:
{prefix}warn {ctx.author.mention} Don't actually read the documentation!
"""
if not ctx.args:
return await ctx.error_reply(
"**Usage:** `{}warn @target <reason>`.".format(ctx.best_prefix)
)
# Extract the target. We do require them to be in the server
splits = ctx.args.split(maxsplit=1)
target_str = splits[0].strip('<@!&> ')
if not target_str.isdigit():
return await ctx.error_reply(
"**Usage:** `{}warn @target <reason>`.\n"
"`target` must be provided by mention or userid.".format(ctx.best_prefix)
)
targetid = int(target_str)
target = ctx.guild.get_member(targetid)
if not target:
return await ctx.error_reply("Cannot warn a user who is not in the server!")
# Extract or prompt for the content
if len(splits) != 2:
try:
content = await ctx.input("Please give a reason for this warning!", timeout=300)
except ResponseTimedOut:
raise ResponseTimedOut("Prompt timed out, the member was not warned.")
else:
content = splits[1].strip()
# Create the warn ticket
ticket = await WarnTicket.create(
ctx.guild.id,
targetid,
ctx.author.id,
content
)
# Attempt to message the member
embed = discord.Embed(
title="You have received a warning!",
description=(
content
),
colour=discord.Colour.red(),
timestamp=datetime.datetime.utcnow()
)
embed.add_field(
name="Info",
value=(
"*Warnings appear in your moderation history. "
"Failure to comply, or repeated warnings, "
"may result in muting, studybanning, or server banning.*"
)
)
embed.set_footer(
icon_url=ctx.guild.icon_url,
text=ctx.guild.name
)
dm_msg = None
try:
dm_msg = await target.send(embed=embed)
except discord.HTTPException:
pass
# Get previous warnings
count = tickets.select_one_where(
guildid=ctx.guild.id,
targetid=targetid,
ticket_type=TicketType.WARNING,
ticket_state=[TicketState.OPEN, TicketState.EXPIRING],
select_columns=('COUNT(*)',)
)[0]
if count == 1:
prev_str = "This is their first warning."
else:
prev_str = "They now have `{}` warnings.".format(count)
await ctx.embed_reply(
"[Ticket #{}]({}): {} has been warned. {}\n{}".format(
ticket.data.guild_ticketid,
ticket.link,
target.mention,
prev_str,
"*Could not DM the user their warning!*" if not dm_msg else ''
)
)