chore(rewrite): Cleanup deprecated code.
This commit is contained in:
@@ -1,7 +0,0 @@
|
||||
# flake8: noqa
|
||||
from .module import module
|
||||
|
||||
from . import help
|
||||
from . import links
|
||||
from . import nerd
|
||||
from . import join_message
|
||||
@@ -1,237 +0,0 @@
|
||||
import discord
|
||||
from cmdClient.checks import is_owner
|
||||
|
||||
from utils.lib import prop_tabulate
|
||||
from utils import interactive, ctx_addons # noqa
|
||||
from wards import is_guild_admin
|
||||
|
||||
from .module import module
|
||||
from .lib import guide_link
|
||||
|
||||
|
||||
new_emoji = " 🆕"
|
||||
new_commands = {'botconfig', 'sponsors'}
|
||||
|
||||
# Set the command groups to appear in the help
|
||||
group_hints = {
|
||||
'Pomodoro': "*Stay in sync with your friends using our timers!*",
|
||||
'Productivity': "*Use these to help you stay focused and productive!*",
|
||||
'Statistics': "*StudyLion leaderboards and study statistics.*",
|
||||
'Economy': "*Buy, sell, and trade with your hard-earned coins!*",
|
||||
'Personal Settings': "*Tell me about yourself!*",
|
||||
'Guild Admin': "*Dangerous administration commands!*",
|
||||
'Guild Configuration': "*Control how I behave in your server.*",
|
||||
'Meta': "*Information about me!*",
|
||||
'Support Us': "*Support the team and keep the project alive by using LionGems!*"
|
||||
}
|
||||
|
||||
standard_group_order = (
|
||||
('Pomodoro', 'Productivity', 'Support Us', 'Statistics', 'Economy', 'Personal Settings', 'Meta'),
|
||||
)
|
||||
|
||||
mod_group_order = (
|
||||
('Moderation', 'Meta'),
|
||||
('Pomodoro', 'Productivity', 'Support Us', 'Statistics', 'Economy', 'Personal Settings')
|
||||
)
|
||||
|
||||
admin_group_order = (
|
||||
('Guild Admin', 'Guild Configuration', 'Moderation', 'Meta'),
|
||||
('Pomodoro', 'Productivity', 'Support Us', 'Statistics', 'Economy', 'Personal Settings')
|
||||
)
|
||||
|
||||
bot_admin_group_order = (
|
||||
('Bot Admin', 'Guild Admin', 'Guild Configuration', 'Moderation', 'Meta'),
|
||||
('Pomodoro', 'Productivity', 'Support Us', 'Statistics', 'Economy', 'Personal Settings')
|
||||
)
|
||||
|
||||
# Help embed format
|
||||
# TODO: Add config fields for this
|
||||
title = "StudyLion Command List"
|
||||
header = """
|
||||
[StudyLion](https://bot.studylions.com/) is a fully featured study assistant \
|
||||
that tracks your study time and offers productivity tools \
|
||||
such as to-do lists, task reminders, private study rooms, group accountability sessions, and much much more.\n
|
||||
Use `{{ctx.best_prefix}}help <command>` (e.g. `{{ctx.best_prefix}}help send`) to learn how to use each command, \
|
||||
or [click here]({guide_link}) for a comprehensive tutorial.
|
||||
""".format(guide_link=guide_link)
|
||||
|
||||
|
||||
@module.cmd("help",
|
||||
group="Meta",
|
||||
desc="StudyLion command list.",
|
||||
aliases=('man', 'ls', 'list'))
|
||||
async def cmd_help(ctx):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}help [cmdname]
|
||||
Description:
|
||||
When used with no arguments, displays a list of commands with brief descriptions.
|
||||
Otherwise, shows documentation for the provided command.
|
||||
Examples:
|
||||
{prefix}help
|
||||
{prefix}help top
|
||||
{prefix}help timezone
|
||||
"""
|
||||
if ctx.arg_str:
|
||||
# Attempt to fetch the command
|
||||
command = ctx.client.cmd_names.get(ctx.arg_str.strip(), None)
|
||||
if command is None:
|
||||
return await ctx.error_reply(
|
||||
("Command `{}` not found!\n"
|
||||
"Write `{}help` to see a list of commands.").format(ctx.args, ctx.best_prefix)
|
||||
)
|
||||
|
||||
smart_help = getattr(command, 'smart_help', None)
|
||||
if smart_help is not None:
|
||||
return await smart_help(ctx)
|
||||
|
||||
help_fields = command.long_help.copy()
|
||||
help_map = {field_name: i for i, (field_name, _) in enumerate(help_fields)}
|
||||
|
||||
if not help_map:
|
||||
return await ctx.reply("No documentation has been written for this command yet!")
|
||||
|
||||
field_pages = [[]]
|
||||
page_fields = field_pages[0]
|
||||
for name, pos in help_map.items():
|
||||
if name.endswith("``"):
|
||||
# Handle codeline help fields
|
||||
page_fields.append((
|
||||
name.strip("`"),
|
||||
"`{}`".format('`\n`'.join(help_fields[pos][1].splitlines()))
|
||||
))
|
||||
elif name.endswith(":"):
|
||||
# Handle property/value help fields
|
||||
lines = help_fields[pos][1].splitlines()
|
||||
|
||||
names = []
|
||||
values = []
|
||||
for line in lines:
|
||||
split = line.split(":", 1)
|
||||
names.append(split[0] if len(split) > 1 else "")
|
||||
values.append(split[-1])
|
||||
|
||||
page_fields.append((
|
||||
name.strip(':'),
|
||||
prop_tabulate(names, values)
|
||||
))
|
||||
elif name == "Related":
|
||||
# Handle the related field
|
||||
names = [cmd_name.strip() for cmd_name in help_fields[pos][1].split(',')]
|
||||
names.sort(key=len)
|
||||
values = [
|
||||
(getattr(ctx.client.cmd_names.get(cmd_name, None), 'desc', '') or '').format(ctx=ctx)
|
||||
for cmd_name in names
|
||||
]
|
||||
page_fields.append((
|
||||
name,
|
||||
prop_tabulate(names, values)
|
||||
))
|
||||
elif name == "PAGEBREAK":
|
||||
page_fields = []
|
||||
field_pages.append(page_fields)
|
||||
else:
|
||||
page_fields.append((name, help_fields[pos][1]))
|
||||
|
||||
# Build the aliases
|
||||
aliases = getattr(command, 'aliases', [])
|
||||
alias_str = "(Aliases `{}`.)".format("`, `".join(aliases)) if aliases else ""
|
||||
|
||||
# Build the embeds
|
||||
pages = []
|
||||
for i, page_fields in enumerate(field_pages):
|
||||
embed = discord.Embed(
|
||||
title="`{}` command documentation. {}".format(
|
||||
command.name,
|
||||
alias_str
|
||||
),
|
||||
colour=discord.Colour(0x9b59b6)
|
||||
)
|
||||
for fieldname, fieldvalue in page_fields:
|
||||
embed.add_field(
|
||||
name=fieldname,
|
||||
value=fieldvalue.format(ctx=ctx, prefix=ctx.best_prefix),
|
||||
inline=False
|
||||
)
|
||||
|
||||
embed.set_footer(
|
||||
text="{}\n[optional] and <required> denote optional and required arguments, respectively.".format(
|
||||
"Page {} of {}".format(i + 1, len(field_pages)) if len(field_pages) > 1 else '',
|
||||
)
|
||||
)
|
||||
pages.append(embed)
|
||||
|
||||
# Post the embed
|
||||
await ctx.pager(pages)
|
||||
else:
|
||||
# Build the command groups
|
||||
cmd_groups = {}
|
||||
for command in ctx.client.cmds:
|
||||
# Get the command group
|
||||
group = getattr(command, 'group', "Misc")
|
||||
cmd_group = cmd_groups.get(group, [])
|
||||
if not cmd_group:
|
||||
cmd_groups[group] = cmd_group
|
||||
|
||||
# Add the command name and description to the group
|
||||
cmd_group.append(
|
||||
(command.name, (getattr(command, 'desc', '') + (new_emoji if command.name in new_commands else '')))
|
||||
)
|
||||
|
||||
# Add any required aliases
|
||||
for alias, desc in getattr(command, 'help_aliases', {}).items():
|
||||
cmd_group.append((alias, desc))
|
||||
|
||||
# Turn the command groups into strings
|
||||
stringy_cmd_groups = {}
|
||||
for group_name, cmd_group in cmd_groups.items():
|
||||
cmd_group.sort(key=lambda tup: len(tup[0]))
|
||||
if ctx.alias == 'ls':
|
||||
stringy_cmd_groups[group_name] = ', '.join(
|
||||
f"`{name}`" for name, _ in cmd_group
|
||||
)
|
||||
else:
|
||||
stringy_cmd_groups[group_name] = prop_tabulate(*zip(*cmd_group))
|
||||
|
||||
# Now put everything into a bunch of embeds
|
||||
if await is_owner.run(ctx):
|
||||
group_order = bot_admin_group_order
|
||||
elif ctx.guild:
|
||||
if is_guild_admin(ctx.author):
|
||||
group_order = admin_group_order
|
||||
elif ctx.guild_settings.mod_role.value in ctx.author.roles:
|
||||
group_order = mod_group_order
|
||||
else:
|
||||
group_order = standard_group_order
|
||||
else:
|
||||
group_order = admin_group_order
|
||||
|
||||
help_embeds = []
|
||||
for page_groups in group_order:
|
||||
embed = discord.Embed(
|
||||
description=header.format(ctx=ctx),
|
||||
colour=discord.Colour(0x9b59b6),
|
||||
title=title
|
||||
)
|
||||
for group in page_groups:
|
||||
group_hint = group_hints.get(group, '').format(ctx=ctx)
|
||||
group_str = stringy_cmd_groups.get(group, None)
|
||||
if group_str:
|
||||
embed.add_field(
|
||||
name=group,
|
||||
value="{}\n{}".format(group_hint, group_str).format(ctx=ctx),
|
||||
inline=False
|
||||
)
|
||||
help_embeds.append(embed)
|
||||
|
||||
# Add the page numbers
|
||||
for i, embed in enumerate(help_embeds):
|
||||
embed.set_footer(text="Page {}/{}".format(i+1, len(help_embeds)))
|
||||
|
||||
# Send the embeds
|
||||
if help_embeds:
|
||||
await ctx.pager(help_embeds)
|
||||
else:
|
||||
await ctx.reply(
|
||||
embed=discord.Embed(description=header, colour=discord.Colour(0x9b59b6))
|
||||
)
|
||||
@@ -1,50 +0,0 @@
|
||||
import discord
|
||||
|
||||
from cmdClient import cmdClient
|
||||
|
||||
from meta import client, conf
|
||||
from .lib import guide_link, animation_link
|
||||
|
||||
|
||||
message = """
|
||||
Thank you for inviting me to your community.
|
||||
Get started by typing `{prefix}help` to see my commands, and `{prefix}config info` \
|
||||
to read about my configuration options!
|
||||
|
||||
To learn how to configure me and use all of my features, \
|
||||
make sure to [click here]({guide_link}) to read our full setup guide.
|
||||
|
||||
Remember, if you need any help configuring me, \
|
||||
want to suggest a feature, report a bug and stay updated, \
|
||||
make sure to join our main support and study server by [clicking here]({support_link}).
|
||||
|
||||
Best of luck with your studies!
|
||||
|
||||
""".format(
|
||||
guide_link=guide_link,
|
||||
support_link=conf.bot.get('support_link'),
|
||||
prefix=client.prefix
|
||||
)
|
||||
|
||||
|
||||
@client.add_after_event('guild_join', priority=0)
|
||||
async def post_join_message(client: cmdClient, guild: discord.Guild):
|
||||
try:
|
||||
await guild.me.edit(nick="Leo")
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
if (channel := guild.system_channel) and channel.permissions_for(guild.me).embed_links:
|
||||
embed = discord.Embed(
|
||||
description=message
|
||||
)
|
||||
embed.set_author(
|
||||
name="Hello everyone! My name is Leo, the StudyLion!",
|
||||
icon_url="https://cdn.discordapp.com/emojis/933610591459872868.webp"
|
||||
)
|
||||
embed.set_image(url=animation_link)
|
||||
try:
|
||||
await channel.send(embed=embed)
|
||||
except discord.HTTPException:
|
||||
# Something went wrong sending the hi message
|
||||
# Not much we can do about this
|
||||
pass
|
||||
@@ -1,5 +0,0 @@
|
||||
guide_link = "https://discord.studylions.com/tutorial"
|
||||
|
||||
animation_link = (
|
||||
"https://media.discordapp.net/attachments/879412267731542047/926837189814419486/ezgif.com-resize.gif"
|
||||
)
|
||||
@@ -1,57 +0,0 @@
|
||||
import discord
|
||||
|
||||
from meta import conf
|
||||
|
||||
from LionContext import LionContext as Context
|
||||
|
||||
from .module import module
|
||||
from .lib import guide_link
|
||||
|
||||
|
||||
@module.cmd(
|
||||
"support",
|
||||
group="Meta",
|
||||
desc=f"Have a question? Join my [support server]({conf.bot.get('support_link')})"
|
||||
)
|
||||
async def cmd_support(ctx: Context):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}support
|
||||
Description:
|
||||
Replies with an invite link to my support server.
|
||||
"""
|
||||
await ctx.reply(
|
||||
f"Click here to join my support server: {conf.bot.get('support_link')}"
|
||||
)
|
||||
|
||||
|
||||
@module.cmd(
|
||||
"invite",
|
||||
group="Meta",
|
||||
desc=f"[Invite me]({conf.bot.get('invite_link')}) to your server so I can help your members stay productive!"
|
||||
)
|
||||
async def cmd_invite(ctx: Context):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}invite
|
||||
Description:
|
||||
Replies with my invite link so you can add me to your server.
|
||||
"""
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
description=f"Click here to add me to your server: {conf.bot.get('invite_link')}"
|
||||
)
|
||||
embed.add_field(
|
||||
name="Setup tips",
|
||||
value=(
|
||||
"Remember to check out `{prefix}help` for the full command list, "
|
||||
"and `{prefix}config info` for the configuration options.\n"
|
||||
"[Click here]({guide}) for our comprehensive setup tutorial, and if you still have questions you can "
|
||||
"join our support server [here]({support}) to talk to our friendly support team!"
|
||||
).format(
|
||||
prefix=ctx.best_prefix,
|
||||
support=conf.bot.get('support_link'),
|
||||
guide=guide_link
|
||||
)
|
||||
)
|
||||
await ctx.reply(embed=embed)
|
||||
@@ -1,3 +0,0 @@
|
||||
from LionModule import LionModule
|
||||
|
||||
module = LionModule("Meta")
|
||||
@@ -1,144 +0,0 @@
|
||||
import datetime
|
||||
import asyncio
|
||||
import discord
|
||||
import psutil
|
||||
import sys
|
||||
import gc
|
||||
|
||||
from data import NOTNULL
|
||||
from data.queries import select_where
|
||||
from utils.lib import prop_tabulate, utc_now
|
||||
|
||||
from LionContext import LionContext as Context
|
||||
|
||||
from .module import module
|
||||
|
||||
|
||||
process = psutil.Process()
|
||||
process.cpu_percent()
|
||||
|
||||
|
||||
@module.cmd(
|
||||
"nerd",
|
||||
group="Meta",
|
||||
desc="Information and statistics about me!"
|
||||
)
|
||||
async def cmd_nerd(ctx: Context):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}nerd
|
||||
Description:
|
||||
View nerdy information and statistics about me!
|
||||
"""
|
||||
# Create embed
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
title="Nerd Panel",
|
||||
description=(
|
||||
"Hi! I'm [StudyLion]({studylion}), a study management bot owned by "
|
||||
"[Ari Horesh]({ari}) and developed by [Conatum#5317]({cona}), with [contributors]({github})."
|
||||
).format(
|
||||
studylion="http://studylions.com/",
|
||||
ari="https://arihoresh.com/",
|
||||
cona="https://github.com/Intery",
|
||||
github="https://github.com/StudyLions/StudyLion"
|
||||
)
|
||||
)
|
||||
|
||||
# ----- Study stats -----
|
||||
# Current studying statistics
|
||||
current_students, current_channels, current_guilds= (
|
||||
ctx.client.data.current_sessions.select_one_where(
|
||||
select_columns=(
|
||||
"COUNT(*) AS studying_count",
|
||||
"COUNT(DISTINCT(channelid)) AS channel_count",
|
||||
"COUNT(DISTINCT(guildid)) AS guild_count"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Past studying statistics
|
||||
past_sessions, past_students, past_duration, past_guilds = ctx.client.data.session_history.select_one_where(
|
||||
select_columns=(
|
||||
"COUNT(*) AS session_count",
|
||||
"COUNT(DISTINCT(userid)) AS user_count",
|
||||
"SUM(duration) / 3600 AS total_hours",
|
||||
"COUNT(DISTINCT(guildid)) AS guild_count"
|
||||
)
|
||||
)
|
||||
|
||||
# Tasklist statistics
|
||||
tasks = ctx.client.data.tasklist.select_one_where(
|
||||
select_columns=(
|
||||
'COUNT(*)'
|
||||
)
|
||||
)[0]
|
||||
|
||||
tasks_completed = ctx.client.data.tasklist.select_one_where(
|
||||
completed_at=NOTNULL,
|
||||
select_columns=(
|
||||
'COUNT(*)'
|
||||
)
|
||||
)[0]
|
||||
|
||||
# Timers
|
||||
timer_count, timer_guilds = ctx.client.data.timers.select_one_where(
|
||||
select_columns=("COUNT(*)", "COUNT(DISTINCT(guildid))")
|
||||
)
|
||||
|
||||
study_fields = {
|
||||
"Currently": f"`{current_students}` people working in `{current_channels}` rooms of `{current_guilds}` guilds",
|
||||
"Recorded": f"`{past_duration}` hours from `{past_students}` people across `{past_sessions}` sessions",
|
||||
"Tasks": f"`{tasks_completed}` out of `{tasks}` tasks completed",
|
||||
"Timers": f"`{timer_count}` timers running in `{timer_guilds}` communities"
|
||||
}
|
||||
study_table = prop_tabulate(*zip(*study_fields.items()))
|
||||
|
||||
# ----- Shard statistics -----
|
||||
shard_number = ctx.client.shard_id
|
||||
shard_count = ctx.client.shard_count
|
||||
guilds = len(ctx.client.guilds)
|
||||
member_count = sum(guild.member_count for guild in ctx.client.guilds)
|
||||
commands = len(ctx.client.cmds)
|
||||
aliases = len(ctx.client.cmd_names)
|
||||
dpy_version = discord.__version__
|
||||
py_version = sys.version.split()[0]
|
||||
data_version, data_time, _ = select_where(
|
||||
"VersionHistory",
|
||||
_extra="ORDER BY time DESC LIMIT 1"
|
||||
)[0]
|
||||
data_timestamp = int(data_time.replace(tzinfo=datetime.timezone.utc).timestamp())
|
||||
|
||||
shard_fields = {
|
||||
"Shard": f"`{shard_number}` of `{shard_count}`",
|
||||
"Guilds": f"`{guilds}` servers with `{member_count}` members (on this shard)",
|
||||
"Commands": f"`{commands}` commands with `{aliases}` keywords",
|
||||
"Version": f"`v{data_version}`, last updated <t:{data_timestamp}:F>",
|
||||
"Py version": f"`{py_version}` running discord.py `{dpy_version}`"
|
||||
}
|
||||
shard_table = prop_tabulate(*zip(*shard_fields.items()))
|
||||
|
||||
|
||||
# ----- Execution statistics -----
|
||||
running_commands = len(ctx.client.active_contexts)
|
||||
tasks = len(asyncio.all_tasks())
|
||||
objects = len(gc.get_objects())
|
||||
cpu_percent = process.cpu_percent()
|
||||
mem_percent = int(process.memory_percent())
|
||||
uptime = int(utc_now().timestamp() - process.create_time())
|
||||
|
||||
execution_fields = {
|
||||
"Running": f"`{running_commands}` commands",
|
||||
"Waiting for": f"`{tasks}` tasks to complete",
|
||||
"Objects": f"`{objects}` loaded in memory",
|
||||
"Usage": f"`{cpu_percent}%` CPU, `{mem_percent}%` MEM",
|
||||
"Uptime": f"`{uptime // (24 * 3600)}` days, `{uptime // 3600 % 24:02}:{uptime // 60 % 60:02}:{uptime % 60:02}`"
|
||||
}
|
||||
execution_table = prop_tabulate(*zip(*execution_fields.items()))
|
||||
|
||||
# ----- Combine and output -----
|
||||
embed.add_field(name="Study Stats", value=study_table, inline=False)
|
||||
embed.add_field(name=f"Shard Info", value=shard_table, inline=False)
|
||||
embed.add_field(name=f"Process Stats", value=execution_table, inline=False)
|
||||
|
||||
await ctx.reply(embed=embed)
|
||||
@@ -1,9 +0,0 @@
|
||||
from .module import module
|
||||
|
||||
from . import data
|
||||
from . import admin
|
||||
|
||||
from . import tickets
|
||||
from . import video
|
||||
|
||||
from . import commands
|
||||
@@ -1,109 +0,0 @@
|
||||
import discord
|
||||
|
||||
from settings import GuildSettings, GuildSetting
|
||||
from wards import guild_admin
|
||||
|
||||
import settings
|
||||
|
||||
from .data import studyban_durations
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class mod_log(settings.Channel, GuildSetting):
|
||||
category = "Moderation"
|
||||
|
||||
attr_name = 'mod_log'
|
||||
_data_column = 'mod_log_channel'
|
||||
|
||||
display_name = "mod_log"
|
||||
desc = "Moderation event logging channel."
|
||||
|
||||
long_desc = (
|
||||
"Channel to post moderation tickets.\n"
|
||||
"These are produced when a manual or automatic moderation action is performed on a member. "
|
||||
"This channel acts as a more context rich moderation history source than the audit log."
|
||||
)
|
||||
|
||||
_chan_type = discord.ChannelType.text
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value:
|
||||
return "Moderation tickets will be posted to {}.".format(self.formatted)
|
||||
else:
|
||||
return "The moderation log has been unset."
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class studyban_role(settings.Role, GuildSetting):
|
||||
category = "Moderation"
|
||||
|
||||
attr_name = 'studyban_role'
|
||||
_data_column = 'studyban_role'
|
||||
|
||||
display_name = "studyban_role"
|
||||
desc = "The role given to members to prevent them from using server study features."
|
||||
|
||||
long_desc = (
|
||||
"This role is to be given to members to prevent them from using the server's study features.\n"
|
||||
"Typically, this role should act as a 'partial mute', and prevent the user from joining study voice channels, "
|
||||
"or participating in study text channels.\n"
|
||||
"It will be given automatically after study related offences, "
|
||||
"such as not enabling video in the video-only channels."
|
||||
)
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value:
|
||||
return "The study ban role is now {}.".format(self.formatted)
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class studyban_durations(settings.SettingList, settings.ListData, settings.Setting):
|
||||
category = "Moderation"
|
||||
|
||||
attr_name = 'studyban_durations'
|
||||
|
||||
_table_interface = studyban_durations
|
||||
_id_column = 'guildid'
|
||||
_data_column = 'duration'
|
||||
_order_column = "rowid"
|
||||
|
||||
_default = [
|
||||
5 * 60,
|
||||
60 * 60,
|
||||
6 * 60 * 60,
|
||||
24 * 60 * 60,
|
||||
168 * 60 * 60,
|
||||
720 * 60 * 60
|
||||
]
|
||||
|
||||
_setting = settings.Duration
|
||||
|
||||
write_ward = guild_admin
|
||||
display_name = "studyban_durations"
|
||||
desc = "Sequence of durations for automatic study bans."
|
||||
|
||||
long_desc = (
|
||||
"This sequence describes how long a member will be automatically study-banned for "
|
||||
"after committing a study-related offence (such as not enabling their video in video only channels).\n"
|
||||
"If the sequence is `1d, 7d, 30d`, for example, the member will be study-banned "
|
||||
"for `1d` on their first offence, `7d` on their second offence, and `30d` on their third. "
|
||||
"On their fourth offence, they will not be unbanned.\n"
|
||||
"This does not count pardoned offences."
|
||||
)
|
||||
accepts = (
|
||||
"Comma separated list of durations in days/hours/minutes/seconds, for example `12h, 1d, 7d, 30d`."
|
||||
)
|
||||
|
||||
# Flat cache, no need to expire objects
|
||||
_cache = {}
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value:
|
||||
return "The automatic study ban durations are now {}.".format(self.formatted)
|
||||
else:
|
||||
return "Automatic study bans will never be reverted."
|
||||
|
||||
|
||||
@@ -1,448 +0,0 @@
|
||||
"""
|
||||
Shared commands for the moderation module.
|
||||
"""
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
import discord
|
||||
|
||||
from cmdClient.lib import ResponseTimedOut
|
||||
from wards import guild_moderator
|
||||
|
||||
from .module import module
|
||||
from .tickets import Ticket, TicketType, TicketState
|
||||
|
||||
|
||||
type_accepts = {
|
||||
'note': TicketType.NOTE,
|
||||
'notes': TicketType.NOTE,
|
||||
'studyban': TicketType.STUDY_BAN,
|
||||
'studybans': TicketType.STUDY_BAN,
|
||||
'warn': TicketType.WARNING,
|
||||
'warns': TicketType.WARNING,
|
||||
'warning': TicketType.WARNING,
|
||||
'warnings': TicketType.WARNING,
|
||||
}
|
||||
|
||||
type_formatted = {
|
||||
TicketType.NOTE: 'NOTE',
|
||||
TicketType.STUDY_BAN: 'STUDYBAN',
|
||||
TicketType.WARNING: 'WARNING',
|
||||
}
|
||||
|
||||
type_summary_formatted = {
|
||||
TicketType.NOTE: 'note',
|
||||
TicketType.STUDY_BAN: 'studyban',
|
||||
TicketType.WARNING: 'warning',
|
||||
}
|
||||
|
||||
state_formatted = {
|
||||
TicketState.OPEN: 'ACTIVE',
|
||||
TicketState.EXPIRING: 'TEMP',
|
||||
TicketState.EXPIRED: 'EXPIRED',
|
||||
TicketState.PARDONED: 'PARDONED'
|
||||
}
|
||||
|
||||
state_summary_formatted = {
|
||||
TicketState.OPEN: 'Active',
|
||||
TicketState.EXPIRING: 'Temporary',
|
||||
TicketState.EXPIRED: 'Expired',
|
||||
TicketState.REVERTED: 'Manually Reverted',
|
||||
TicketState.PARDONED: 'Pardoned'
|
||||
}
|
||||
|
||||
|
||||
@module.cmd(
|
||||
"tickets",
|
||||
group="Moderation",
|
||||
desc="View and filter the server moderation tickets.",
|
||||
flags=('active', 'type=')
|
||||
)
|
||||
@guild_moderator()
|
||||
async def cmd_tickets(ctx, flags):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}tickets [@user] [--type <type>] [--active]
|
||||
Description:
|
||||
Display and optionally filter the moderation event history in this guild.
|
||||
Flags::
|
||||
type: Filter by ticket type. See **Ticket Types** below.
|
||||
active: Only show in-effect tickets (i.e. hide expired and pardoned ones).
|
||||
Ticket Types::
|
||||
note: Moderation notes.
|
||||
warn: Moderation warnings, both manual and automatic.
|
||||
studyban: Bans from using study features from abusing the study system.
|
||||
blacklist: Complete blacklisting from using my commands.
|
||||
Ticket States::
|
||||
Active: Active tickets that will not automatically expire.
|
||||
Temporary: Active tickets that will automatically expire after a set duration.
|
||||
Expired: Tickets that have automatically expired.
|
||||
Reverted: Tickets with actions that have been reverted.
|
||||
Pardoned: Tickets that have been pardoned and no longer apply to the user.
|
||||
Examples:
|
||||
{prefix}tickets {ctx.guild.owner.mention} --type warn --active
|
||||
"""
|
||||
# Parse filter fields
|
||||
# First the user
|
||||
if ctx.args:
|
||||
userstr = ctx.args.strip('<@!&> ')
|
||||
if not userstr.isdigit():
|
||||
return await ctx.error_reply(
|
||||
"**Usage:** `{prefix}tickets [@user] [--type <type>] [--active]`.\n"
|
||||
"Please provide the `user` as a mention or id!".format(prefix=ctx.best_prefix)
|
||||
)
|
||||
filter_userid = int(userstr)
|
||||
else:
|
||||
filter_userid = None
|
||||
|
||||
if flags['type']:
|
||||
typestr = flags['type'].lower()
|
||||
if typestr not in type_accepts:
|
||||
return await ctx.error_reply(
|
||||
"Please see `{prefix}help tickets` for the valid ticket types!".format(prefix=ctx.best_prefix)
|
||||
)
|
||||
filter_type = type_accepts[typestr]
|
||||
else:
|
||||
filter_type = None
|
||||
|
||||
filter_active = flags['active']
|
||||
|
||||
# Build the filter arguments
|
||||
filters = {'guildid': ctx.guild.id}
|
||||
if filter_userid:
|
||||
filters['targetid'] = filter_userid
|
||||
if filter_type:
|
||||
filters['ticket_type'] = filter_type
|
||||
if filter_active:
|
||||
filters['ticket_state'] = [TicketState.OPEN, TicketState.EXPIRING]
|
||||
|
||||
# Fetch the tickets with these filters
|
||||
tickets = Ticket.fetch_tickets(**filters)
|
||||
|
||||
if not tickets:
|
||||
if filters:
|
||||
return await ctx.embed_reply("There are no tickets with these criteria!")
|
||||
else:
|
||||
return await ctx.embed_reply("There are no moderation tickets in this server!")
|
||||
|
||||
tickets = sorted(tickets, key=lambda ticket: ticket.data.guild_ticketid, reverse=True)
|
||||
ticket_map = {ticket.data.guild_ticketid: ticket for ticket in tickets}
|
||||
|
||||
# Build the format string based on the filters
|
||||
components = []
|
||||
# Ticket id with link to message in mod log
|
||||
components.append("[#{ticket.data.guild_ticketid}]({ticket.link})")
|
||||
# Ticket creation date
|
||||
components.append("<t:{timestamp:.0f}:d>")
|
||||
# Ticket type, with current state
|
||||
if filter_type is None:
|
||||
if not filter_active:
|
||||
components.append("`{ticket_type}{ticket_state}`")
|
||||
else:
|
||||
components.append("`{ticket_type}`")
|
||||
elif not filter_active:
|
||||
components.append("`{ticket_real_state}`")
|
||||
if not filter_userid:
|
||||
# Ticket user
|
||||
components.append("<@{ticket.data.targetid}>")
|
||||
if filter_userid or (filter_active and filter_type):
|
||||
# Truncated ticket content
|
||||
components.append("{content}")
|
||||
|
||||
format_str = ' | '.join(components)
|
||||
|
||||
# Break tickets into blocks
|
||||
blocks = [tickets[i:i+10] for i in range(0, len(tickets), 10)]
|
||||
|
||||
# Build pages of tickets
|
||||
ticket_pages = []
|
||||
for block in blocks:
|
||||
ticket_page = []
|
||||
|
||||
type_len = max(len(type_formatted[ticket.type]) for ticket in block)
|
||||
state_len = max(len(state_formatted[ticket.state]) for ticket in block)
|
||||
for ticket in block:
|
||||
# First truncate content if required
|
||||
content = ticket.data.content
|
||||
if len(content) > 40:
|
||||
content = content[:37] + '...'
|
||||
|
||||
# Build ticket line
|
||||
line = format_str.format(
|
||||
ticket=ticket,
|
||||
timestamp=ticket.data.created_at.timestamp(),
|
||||
ticket_type=type_formatted[ticket.type],
|
||||
type_len=type_len,
|
||||
ticket_state=" [{}]".format(state_formatted[ticket.state]) if ticket.state != TicketState.OPEN else '',
|
||||
ticket_real_state=state_formatted[ticket.state],
|
||||
state_len=state_len,
|
||||
content=content
|
||||
)
|
||||
if ticket.state == TicketState.PARDONED:
|
||||
line = "~~{}~~".format(line)
|
||||
|
||||
# Add to current page
|
||||
ticket_page.append(line)
|
||||
# Combine lines and add page to pages
|
||||
ticket_pages.append('\n'.join(ticket_page))
|
||||
|
||||
# Build active ticket type summary
|
||||
freq = defaultdict(int)
|
||||
for ticket in tickets:
|
||||
if ticket.state != TicketState.PARDONED:
|
||||
freq[ticket.type] += 1
|
||||
summary_pairs = [
|
||||
(num, type_summary_formatted[ttype] + ('s' if num > 1 else ''))
|
||||
for ttype, num in freq.items()
|
||||
]
|
||||
summary_pairs.sort(key=lambda pair: pair[0])
|
||||
# num_len = max(len(str(num)) for num in freq.values())
|
||||
# type_summary = '\n'.join(
|
||||
# "**`{:<{}}`** {}".format(pair[0], num_len, pair[1])
|
||||
# for pair in summary_pairs
|
||||
# )
|
||||
|
||||
# # Build status summary
|
||||
# freq = defaultdict(int)
|
||||
# for ticket in tickets:
|
||||
# freq[ticket.state] += 1
|
||||
# num_len = max(len(str(num)) for num in freq.values())
|
||||
# status_summary = '\n'.join(
|
||||
# "**`{:<{}}`** {}".format(freq[state], num_len, state_str)
|
||||
# for state, state_str in state_summary_formatted.items()
|
||||
# if state in freq
|
||||
# )
|
||||
|
||||
summary_strings = [
|
||||
"**`{}`** {}".format(*pair) for pair in summary_pairs
|
||||
]
|
||||
if len(summary_strings) > 2:
|
||||
summary = ', '.join(summary_strings[:-1]) + ', and ' + summary_strings[-1]
|
||||
elif len(summary_strings) == 2:
|
||||
summary = ' and '.join(summary_strings)
|
||||
else:
|
||||
summary = ''.join(summary_strings)
|
||||
if summary:
|
||||
summary += '.'
|
||||
|
||||
# Build embed info
|
||||
title = "{}{}{}".format(
|
||||
"Active " if filter_active else '',
|
||||
"{} tickets ".format(type_formatted[filter_type]) if filter_type else "Tickets ",
|
||||
(" for {}".format(ctx.guild.get_member(filter_userid) or filter_userid)
|
||||
if filter_userid else " in {}".format(ctx.guild.name))
|
||||
)
|
||||
footer = "Click a ticket id to jump to it, or type the number to show the full ticket."
|
||||
page_count = len(blocks)
|
||||
if page_count > 1:
|
||||
footer += "\nPage {{page_num}}/{}".format(page_count)
|
||||
|
||||
# Create embeds
|
||||
embeds = [
|
||||
discord.Embed(
|
||||
title=title,
|
||||
description="{}\n{}".format(summary, page),
|
||||
colour=discord.Colour.orange(),
|
||||
).set_footer(text=footer.format(page_num=i+1))
|
||||
for i, page in enumerate(ticket_pages)
|
||||
]
|
||||
|
||||
# Run output with cancellation and listener
|
||||
out_msg = await ctx.pager(embeds, add_cancel=True)
|
||||
old_task = _displays.pop((ctx.ch.id, ctx.author.id), None)
|
||||
if old_task:
|
||||
old_task.cancel()
|
||||
_displays[(ctx.ch.id, ctx.author.id)] = display_task = asyncio.create_task(_ticket_display(ctx, ticket_map))
|
||||
ctx.tasks.append(display_task)
|
||||
await ctx.cancellable(out_msg, add_reaction=False)
|
||||
|
||||
|
||||
_displays = {} # (channelid, userid) -> Task
|
||||
async def _ticket_display(ctx, ticket_map):
|
||||
"""
|
||||
Display tickets when the ticket number is entered.
|
||||
"""
|
||||
current_ticket_msg = None
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Wait for a number
|
||||
try:
|
||||
result = await ctx.client.wait_for(
|
||||
"message",
|
||||
check=lambda msg: (msg.author == ctx.author
|
||||
and msg.channel == ctx.ch
|
||||
and msg.content.isdigit()
|
||||
and int(msg.content) in ticket_map),
|
||||
timeout=60
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
return
|
||||
|
||||
# Delete the response
|
||||
try:
|
||||
await result.delete()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
# Display the ticket
|
||||
embed = ticket_map[int(result.content)].msg_args['embed']
|
||||
if current_ticket_msg:
|
||||
try:
|
||||
await current_ticket_msg.edit(embed=embed)
|
||||
except discord.HTTPException:
|
||||
current_ticket_msg = None
|
||||
|
||||
if not current_ticket_msg:
|
||||
try:
|
||||
current_ticket_msg = await ctx.reply(embed=embed)
|
||||
except discord.HTTPException:
|
||||
return
|
||||
asyncio.create_task(ctx.offer_delete(current_ticket_msg))
|
||||
except asyncio.CancelledError:
|
||||
if current_ticket_msg:
|
||||
try:
|
||||
await current_ticket_msg.delete()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@module.cmd(
|
||||
"pardon",
|
||||
group="Moderation",
|
||||
desc="Pardon a ticket, or clear a member's moderation history.",
|
||||
flags=('type=',)
|
||||
)
|
||||
@guild_moderator()
|
||||
async def cmd_pardon(ctx, flags):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}pardon ticketid, ticketid, ticketid
|
||||
{prefix}pardon @user [--type <type>]
|
||||
Description:
|
||||
Marks the given tickets as no longer applicable.
|
||||
These tickets will not be considered when calculating automod actions such as automatic study bans.
|
||||
|
||||
This may be used to mark warns or other tickets as no longer in-effect.
|
||||
If the ticket is active when it is pardoned, it will be reverted, and any expiry cancelled.
|
||||
|
||||
Use the `{prefix}tickets` command to view the relevant tickets.
|
||||
Flags::
|
||||
type: Filter by ticket type. See **Ticket Types** in `{prefix}help tickets`.
|
||||
Examples:
|
||||
{prefix}pardon 21
|
||||
{prefix}pardon {ctx.guild.owner.mention} --type warn
|
||||
"""
|
||||
usage = "**Usage**: `{prefix}pardon ticketid` or `{prefix}pardon @user`.".format(prefix=ctx.best_prefix)
|
||||
if not ctx.args:
|
||||
return await ctx.error_reply(
|
||||
usage
|
||||
)
|
||||
|
||||
# Parse provided tickets or filters
|
||||
targetid = None
|
||||
ticketids = []
|
||||
args = {'guildid': ctx.guild.id}
|
||||
if ',' in ctx.args:
|
||||
# Assume provided numbers are ticketids.
|
||||
items = [item.strip() for item in ctx.args.split(',')]
|
||||
if not all(item.isdigit() for item in items):
|
||||
return await ctx.error_reply(usage)
|
||||
ticketids = [int(item) for item in items]
|
||||
args['guild_ticketid'] = ticketids
|
||||
else:
|
||||
# Guess whether the provided numbers were ticketids or not
|
||||
idstr = ctx.args.strip('<@!&> ')
|
||||
if not idstr.isdigit():
|
||||
return await ctx.error_reply(usage)
|
||||
|
||||
maybe_id = int(idstr)
|
||||
if maybe_id > 4194304: # Testing whether it is greater than the minimum snowflake id
|
||||
# Assume userid
|
||||
targetid = maybe_id
|
||||
args['targetid'] = maybe_id
|
||||
|
||||
# Add the type filter if provided
|
||||
if flags['type']:
|
||||
typestr = flags['type'].lower()
|
||||
if typestr not in type_accepts:
|
||||
return await ctx.error_reply(
|
||||
"Please see `{prefix}help tickets` for the valid ticket types!".format(prefix=ctx.best_prefix)
|
||||
)
|
||||
args['ticket_type'] = type_accepts[typestr]
|
||||
else:
|
||||
# Assume guild ticketid
|
||||
ticketids = [maybe_id]
|
||||
args['guild_ticketid'] = maybe_id
|
||||
|
||||
# Fetch the matching tickets
|
||||
tickets = Ticket.fetch_tickets(**args)
|
||||
|
||||
# Check whether we have the right selection of tickets
|
||||
if targetid and not tickets:
|
||||
return await ctx.error_reply(
|
||||
"<@{}> has no matching tickets to pardon!"
|
||||
)
|
||||
if ticketids and len(ticketids) != len(tickets):
|
||||
# Not all of the ticketids were valid
|
||||
difference = list(set(ticketids).difference(ticket.ticketid for ticket in tickets))
|
||||
if len(difference) == 1:
|
||||
return await ctx.error_reply(
|
||||
"Couldn't find ticket `{}`!".format(difference[0])
|
||||
)
|
||||
else:
|
||||
return await ctx.error_reply(
|
||||
"Couldn't find any of the following tickets:\n`{}`".format(
|
||||
'`, `'.join(difference)
|
||||
)
|
||||
)
|
||||
|
||||
# Check whether there are any tickets left to pardon
|
||||
to_pardon = [ticket for ticket in tickets if ticket.state != TicketState.PARDONED]
|
||||
if not to_pardon:
|
||||
if ticketids and len(tickets) == 1:
|
||||
ticket = tickets[0]
|
||||
return await ctx.error_reply(
|
||||
"[Ticket #{}]({}) is already pardoned!".format(ticket.data.guild_ticketid, ticket.link)
|
||||
)
|
||||
else:
|
||||
return await ctx.error_reply(
|
||||
"All of these tickets are already pardoned!"
|
||||
)
|
||||
|
||||
# We now know what tickets we want to pardon
|
||||
# Request the pardon reason
|
||||
try:
|
||||
reason = await ctx.input("Please provide a reason for the pardon.")
|
||||
except ResponseTimedOut:
|
||||
raise ResponseTimedOut("Prompt timed out, no tickets were pardoned.")
|
||||
|
||||
# Pardon the tickets
|
||||
for ticket in to_pardon:
|
||||
await ticket.pardon(ctx.author, reason)
|
||||
|
||||
# Finally, ack the pardon
|
||||
if targetid:
|
||||
await ctx.embed_reply(
|
||||
"The active {}s for <@{}> have been cleared.".format(
|
||||
type_summary_formatted[args['ticket_type']] if flags['type'] else 'ticket',
|
||||
targetid
|
||||
)
|
||||
)
|
||||
elif len(to_pardon) == 1:
|
||||
ticket = to_pardon[0]
|
||||
await ctx.embed_reply(
|
||||
"[Ticket #{}]({}) was pardoned.".format(
|
||||
ticket.data.guild_ticketid,
|
||||
ticket.link
|
||||
)
|
||||
)
|
||||
else:
|
||||
await ctx.embed_reply(
|
||||
"The following tickets were pardoned.\n{}".format(
|
||||
", ".join(
|
||||
"[#{}]({})".format(ticket.data.guild_ticketid, ticket.link)
|
||||
for ticket in to_pardon
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -1,19 +0,0 @@
|
||||
from data import Table, RowTable
|
||||
|
||||
|
||||
studyban_durations = Table('studyban_durations')
|
||||
|
||||
ticket_info = RowTable(
|
||||
'ticket_info',
|
||||
('ticketid', 'guild_ticketid',
|
||||
'guildid', 'targetid', 'ticket_type', 'ticket_state', 'moderator_id', 'auto',
|
||||
'log_msg_id', 'created_at',
|
||||
'content', 'context', 'addendum', 'duration',
|
||||
'file_name', 'file_data',
|
||||
'expiry',
|
||||
'pardoned_by', 'pardoned_at', 'pardoned_reason'),
|
||||
'ticketid',
|
||||
cache_size=20000
|
||||
)
|
||||
|
||||
tickets = Table('tickets')
|
||||
@@ -1,4 +0,0 @@
|
||||
from cmdClient import Module
|
||||
|
||||
|
||||
module = Module("Moderation")
|
||||
@@ -1,486 +0,0 @@
|
||||
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()
|
||||
@@ -1,4 +0,0 @@
|
||||
from .Ticket import Ticket, TicketType, TicketState
|
||||
from .studybans import StudyBanTicket
|
||||
from .notes import NoteTicket
|
||||
from .warns import WarnTicket
|
||||
@@ -1,112 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
)
|
||||
@@ -1,126 +0,0 @@
|
||||
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
|
||||
)
|
||||
@@ -1,153 +0,0 @@
|
||||
"""
|
||||
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 ''
|
||||
)
|
||||
)
|
||||
@@ -1,5 +0,0 @@
|
||||
from . import module
|
||||
|
||||
from . import data
|
||||
from . import config
|
||||
from . import commands
|
||||
@@ -1,14 +0,0 @@
|
||||
from .module import module
|
||||
|
||||
|
||||
@module.cmd(
|
||||
name="sponsors",
|
||||
group="Meta",
|
||||
desc="Check out our wonderful partners!",
|
||||
)
|
||||
async def cmd_sponsors(ctx):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}sponsors
|
||||
"""
|
||||
await ctx.reply(**ctx.client.settings.sponsor_message.args(ctx))
|
||||
@@ -1,92 +0,0 @@
|
||||
from cmdClient.checks import is_owner
|
||||
|
||||
from settings import AppSettings, Setting, KeyValueData, ListData
|
||||
from settings.setting_types import Message, String, GuildIDList
|
||||
|
||||
from meta import client
|
||||
from core.data import app_config
|
||||
|
||||
from .data import guild_whitelist
|
||||
|
||||
@AppSettings.attach_setting
|
||||
class sponsor_prompt(String, KeyValueData, Setting):
|
||||
attr_name = 'sponsor_prompt'
|
||||
_default = None
|
||||
|
||||
write_ward = is_owner
|
||||
|
||||
display_name = 'sponsor_prompt'
|
||||
category = 'Sponsors'
|
||||
desc = "Text to send after core commands to encourage checking `sponsors`."
|
||||
long_desc = (
|
||||
"Text posted after several commands to encourage users to check the `sponsors` command. "
|
||||
"Occurences of `{{prefix}}` will be replaced by the bot prefix."
|
||||
)
|
||||
|
||||
_quote = False
|
||||
|
||||
_table_interface = app_config
|
||||
_id_column = 'appid'
|
||||
_key_column = 'key'
|
||||
_value_column = 'value'
|
||||
_key = 'sponsor_prompt'
|
||||
|
||||
@classmethod
|
||||
def _data_to_value(cls, id, data, **kwargs):
|
||||
if data:
|
||||
return data.replace("{prefix}", client.prefix)
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value:
|
||||
return "The sponsor prompt has been update."
|
||||
else:
|
||||
return "The sponsor prompt has been cleared."
|
||||
|
||||
|
||||
@AppSettings.attach_setting
|
||||
class sponsor_message(Message, KeyValueData, Setting):
|
||||
attr_name = 'sponsor_message'
|
||||
_default = '{"content": "Coming Soon!"}'
|
||||
|
||||
write_ward = is_owner
|
||||
|
||||
display_name = 'sponsor_message'
|
||||
category = 'Sponsors'
|
||||
desc = "`sponsors` command response."
|
||||
|
||||
long_desc = (
|
||||
"Message to reply with when a user runs the `sponsors` command."
|
||||
)
|
||||
|
||||
_table_interface = app_config
|
||||
_id_column = 'appid'
|
||||
_key_column = 'key'
|
||||
_value_column = 'value'
|
||||
_key = 'sponsor_message'
|
||||
|
||||
_cmd_str = "{prefix}sponsors --edit"
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
return "The `sponsors` command message has been updated."
|
||||
|
||||
|
||||
@AppSettings.attach_setting
|
||||
class sponsor_guild_whitelist(GuildIDList, ListData, Setting):
|
||||
attr_name = 'sponsor_guild_whitelist'
|
||||
write_ward = is_owner
|
||||
|
||||
category = 'Sponsors'
|
||||
display_name = 'sponsor_hidden_in'
|
||||
desc = "Guilds where the sponsor prompt is not displayed."
|
||||
long_desc = (
|
||||
"A list of guilds where the sponsor prompt hint will be hidden (see the `sponsor_prompt` setting)."
|
||||
)
|
||||
|
||||
_table_interface = guild_whitelist
|
||||
_id_column = 'appid'
|
||||
_data_column = 'guildid'
|
||||
_force_unique = True
|
||||
@@ -1,4 +0,0 @@
|
||||
from data import Table
|
||||
|
||||
|
||||
guild_whitelist = Table("sponsor_guild_whitelist")
|
||||
@@ -1,33 +0,0 @@
|
||||
import discord
|
||||
|
||||
from LionModule import LionModule
|
||||
from LionContext import LionContext
|
||||
|
||||
from meta import client
|
||||
|
||||
|
||||
module = LionModule("Sponsor")
|
||||
|
||||
|
||||
sponsored_commands = {'profile', 'stats', 'weekly', 'monthly'}
|
||||
|
||||
|
||||
@LionContext.reply.add_wrapper
|
||||
async def sponsor_reply_wrapper(func, ctx: LionContext, *args, **kwargs):
|
||||
if ctx.cmd and ctx.cmd.name in sponsored_commands:
|
||||
if (prompt := ctx.client.settings.sponsor_prompt.value):
|
||||
if ctx.guild:
|
||||
show = ctx.guild.id not in ctx.client.settings.sponsor_guild_whitelist.value
|
||||
show = show and not ctx.client.data.premium_guilds.queries.fetch_guild(ctx.guild.id)
|
||||
else:
|
||||
show = True
|
||||
|
||||
if show:
|
||||
sponsor_hint = discord.Embed(
|
||||
description=prompt,
|
||||
colour=discord.Colour.dark_theme()
|
||||
)
|
||||
if 'embed' not in kwargs:
|
||||
kwargs['embed'] = sponsor_hint
|
||||
|
||||
return await func(ctx, *args, **kwargs)
|
||||
@@ -1,5 +0,0 @@
|
||||
from .module import module
|
||||
|
||||
from . import admin
|
||||
from . import data
|
||||
from . import tracker
|
||||
@@ -1,83 +0,0 @@
|
||||
from settings import GuildSettings, GuildSetting
|
||||
from wards import guild_admin
|
||||
|
||||
import settings
|
||||
|
||||
from .data import workout_channels
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class workout_length(settings.Integer, GuildSetting):
|
||||
category = "Workout"
|
||||
|
||||
attr_name = "min_workout_length"
|
||||
_data_column = "min_workout_length"
|
||||
|
||||
display_name = "min_workout_length"
|
||||
desc = "Minimum length of a workout."
|
||||
|
||||
_default = 20
|
||||
|
||||
long_desc = (
|
||||
"Minimun time a user must spend in a workout channel for it to count as a valid workout. "
|
||||
"Value must be given in minutes."
|
||||
)
|
||||
_accepts = "An integer number of minutes."
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
return "The minimum workout length is now `{}` minutes.".format(self.formatted)
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class workout_reward(settings.Integer, GuildSetting):
|
||||
category = "Workout"
|
||||
|
||||
attr_name = "workout_reward"
|
||||
_data_column = "workout_reward"
|
||||
|
||||
display_name = "workout_reward"
|
||||
desc = "Number of daily LionCoins to reward for completing a workout."
|
||||
|
||||
_default = 350
|
||||
|
||||
long_desc = (
|
||||
"Number of LionCoins given when a member completes their daily workout."
|
||||
)
|
||||
_accepts = "An integer number of LionCoins."
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
return "The workout reward is now `{}` LionCoins.".format(self.formatted)
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class workout_channels_setting(settings.ChannelList, settings.ListData, settings.Setting):
|
||||
category = "Workout"
|
||||
|
||||
attr_name = 'workout_channels'
|
||||
|
||||
_table_interface = workout_channels
|
||||
_id_column = 'guildid'
|
||||
_data_column = 'channelid'
|
||||
_setting = settings.VoiceChannel
|
||||
|
||||
write_ward = guild_admin
|
||||
display_name = "workout_channels"
|
||||
desc = "Channels in which members can do workouts."
|
||||
|
||||
_force_unique = True
|
||||
|
||||
long_desc = (
|
||||
"Sessions in these channels will be treated as workouts."
|
||||
)
|
||||
|
||||
# Flat cache, no need to expire objects
|
||||
_cache = {}
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value:
|
||||
return "The workout channels have been updated:\n{}".format(self.formatted)
|
||||
else:
|
||||
return "The workout channels have been removed."
|
||||
@@ -1,10 +0,0 @@
|
||||
from data import Table, RowTable
|
||||
|
||||
|
||||
workout_channels = Table('workout_channels')
|
||||
|
||||
workout_sessions = RowTable(
|
||||
'workout_sessions',
|
||||
('sessionid', 'guildid', 'userid', 'start_time', 'duration', 'channelid'),
|
||||
'sessionid'
|
||||
)
|
||||
@@ -1,4 +0,0 @@
|
||||
from LionModule import LionModule
|
||||
|
||||
|
||||
module = LionModule("Workout")
|
||||
@@ -1,256 +0,0 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import datetime as dt
|
||||
import discord
|
||||
|
||||
from core import Lion
|
||||
from settings import GuildSettings
|
||||
from meta import client
|
||||
from data import NULL, tables
|
||||
from data.conditions import THIS_SHARD
|
||||
|
||||
from .module import module
|
||||
from .data import workout_sessions
|
||||
from . import admin
|
||||
|
||||
|
||||
leave_tasks = {}
|
||||
|
||||
|
||||
async def on_workout_join(member):
|
||||
key = (member.guild.id, member.id)
|
||||
|
||||
# Cancel a leave task if the member rejoined in time
|
||||
if member.id in leave_tasks:
|
||||
leave_tasks[key].cancel()
|
||||
leave_tasks.pop(key)
|
||||
return
|
||||
|
||||
# Create a started workout entry
|
||||
workout = workout_sessions.create_row(
|
||||
guildid=member.guild.id,
|
||||
userid=member.id,
|
||||
channelid=member.voice.channel.id
|
||||
)
|
||||
|
||||
# Add to current workouts
|
||||
client.objects['current_workouts'][key] = workout
|
||||
|
||||
# Log
|
||||
client.log(
|
||||
"User '{m.name}'(uid:{m.id}) started a workout in channel "
|
||||
"'{m.voice.channel.name}' (cid:{m.voice.channel.id}) "
|
||||
"of guild '{m.guild.name}' (gid:{m.guild.id}).".format(m=member),
|
||||
context="WORKOUT_STARTED"
|
||||
)
|
||||
GuildSettings(member.guild.id).event_log.log(
|
||||
"{} started a workout in {}".format(
|
||||
member.mention,
|
||||
member.voice.channel.mention
|
||||
), title="Workout Started"
|
||||
)
|
||||
|
||||
|
||||
async def on_workout_leave(member):
|
||||
key = (member.guild.id, member.id)
|
||||
|
||||
# Create leave task in case of temporary disconnect
|
||||
task = asyncio.create_task(asyncio.sleep(3))
|
||||
leave_tasks[key] = task
|
||||
|
||||
# Wait for the leave task, abort if it gets cancelled
|
||||
try:
|
||||
await task
|
||||
if member.id in leave_tasks:
|
||||
if leave_tasks[key] == task:
|
||||
leave_tasks.pop(key)
|
||||
else:
|
||||
return
|
||||
except asyncio.CancelledError:
|
||||
# Task was cancelled by rejoining
|
||||
if key in leave_tasks and leave_tasks[key] == task:
|
||||
leave_tasks.pop(key)
|
||||
return
|
||||
|
||||
# Retrieve workout row and remove from current workouts
|
||||
workout = client.objects['current_workouts'].pop(key)
|
||||
|
||||
await workout_left(member, workout)
|
||||
|
||||
|
||||
async def workout_left(member, workout):
|
||||
time_diff = (dt.datetime.utcnow() - workout.start_time).total_seconds()
|
||||
min_length = GuildSettings(member.guild.id).min_workout_length.value
|
||||
if time_diff < 60 * min_length:
|
||||
# Left workout before it was finished. Log and delete
|
||||
client.log(
|
||||
"User '{m.name}'(uid:{m.id}) left their workout in guild '{m.guild.name}' (gid:{m.guild.id}) "
|
||||
"before it was complete! ({diff:.2f} minutes). Deleting workout.\n"
|
||||
"{workout}".format(
|
||||
m=member,
|
||||
diff=time_diff / 60,
|
||||
workout=workout
|
||||
),
|
||||
context="WORKOUT_ABORTED",
|
||||
post=True
|
||||
)
|
||||
GuildSettings(member.guild.id).event_log.log(
|
||||
"{} left their workout before it was complete! (`{:.2f}` minutes)".format(
|
||||
member.mention,
|
||||
time_diff / 60,
|
||||
), title="Workout Left"
|
||||
)
|
||||
workout_sessions.delete_where(sessionid=workout.sessionid)
|
||||
else:
|
||||
# Completed the workout
|
||||
client.log(
|
||||
"User '{m.name}'(uid:{m.id}) completed their daily workout in guild '{m.guild.name}' (gid:{m.guild.id}) "
|
||||
"({diff:.2f} minutes). Saving workout and notifying user.\n"
|
||||
"{workout}".format(
|
||||
m=member,
|
||||
diff=time_diff / 60,
|
||||
workout=workout
|
||||
),
|
||||
context="WORKOUT_COMPLETED",
|
||||
post=True
|
||||
)
|
||||
workout.duration = time_diff
|
||||
await workout_complete(member, workout)
|
||||
|
||||
|
||||
async def workout_complete(member, workout):
|
||||
key = (member.guild.id, member.id)
|
||||
|
||||
# update and notify
|
||||
user = Lion.fetch(*key)
|
||||
user_data = user.data
|
||||
with user_data.batch_update():
|
||||
user_data.workout_count = user_data.workout_count + 1
|
||||
user_data.last_workout_start = workout.start_time
|
||||
|
||||
settings = GuildSettings(member.guild.id)
|
||||
reward = settings.workout_reward.value
|
||||
user.addCoins(reward, bonus=True)
|
||||
|
||||
settings.event_log.log(
|
||||
"{} completed their daily workout and was rewarded `{}` coins! (`{:.2f}` minutes)".format(
|
||||
member.mention,
|
||||
int(reward * user.economy_bonus),
|
||||
workout.duration / 60,
|
||||
), title="Workout Completed"
|
||||
)
|
||||
|
||||
embed = discord.Embed(
|
||||
description=(
|
||||
"Congratulations on completing your daily workout!\n"
|
||||
"You have been rewarded with `{}` LionCoins. Good job!".format(int(reward * user.economy_bonus))
|
||||
),
|
||||
timestamp=dt.datetime.utcnow(),
|
||||
colour=discord.Color.orange()
|
||||
)
|
||||
embed.set_footer(
|
||||
text=member.guild.name,
|
||||
icon_url=member.guild.icon_url
|
||||
)
|
||||
try:
|
||||
await member.send(embed=embed)
|
||||
except discord.Forbidden:
|
||||
client.log(
|
||||
"Couldn't notify user '{m.name}'(uid:{m.id}) about their completed workout! "
|
||||
"They might have me blocked.".format(m=member),
|
||||
context="WORKOUT_COMPLETED",
|
||||
post=True
|
||||
)
|
||||
|
||||
|
||||
@client.add_after_event("voice_state_update")
|
||||
async def workout_voice_tracker(client, member, before, after):
|
||||
# Wait until launch tasks are complete
|
||||
while not module.ready:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
if member.bot:
|
||||
return
|
||||
if member.id in client.user_blacklist():
|
||||
return
|
||||
if member.id in client.objects['ignored_members'][member.guild.id]:
|
||||
return
|
||||
|
||||
# Check whether we are moving to/from a workout channel
|
||||
settings = GuildSettings(member.guild.id)
|
||||
channels = settings.workout_channels.value
|
||||
from_workout = before.channel in channels
|
||||
to_workout = after.channel in channels
|
||||
|
||||
if to_workout ^ from_workout:
|
||||
# Ensure guild row exists
|
||||
tables.guild_config.fetch_or_create(member.guild.id)
|
||||
|
||||
# Fetch workout user
|
||||
user = Lion.fetch(member.guild.id, member.id)
|
||||
|
||||
# Ignore all workout events from users who have already completed their workout today
|
||||
if user.data.last_workout_start is not None:
|
||||
last_date = user.localize(user.data.last_workout_start).date()
|
||||
today = user.localize(dt.datetime.utcnow()).date()
|
||||
if last_date == today:
|
||||
return
|
||||
|
||||
# TODO: Check if they have completed a workout today, if so, ignore
|
||||
if to_workout and not from_workout:
|
||||
await on_workout_join(member)
|
||||
elif from_workout and not to_workout:
|
||||
if (member.guild.id, member.id) in client.objects['current_workouts']:
|
||||
await on_workout_leave(member)
|
||||
else:
|
||||
client.log(
|
||||
"Possible missed workout!\n"
|
||||
"Member '{m.name}'(uid:{m.id}) left the workout channel '{c.name}'(cid:{c.id}) "
|
||||
"in guild '{m.guild.name}'(gid:{m.guild.id}), but we never saw them join!".format(
|
||||
m=member,
|
||||
c=before.channel
|
||||
),
|
||||
context="WORKOUT_TRACKER",
|
||||
level=logging.ERROR,
|
||||
post=True
|
||||
)
|
||||
settings.event_log.log(
|
||||
"{} left the workout channel {}, but I never saw them join!".format(
|
||||
member.mention,
|
||||
before.channel.mention,
|
||||
), title="Possible Missed Workout!"
|
||||
)
|
||||
|
||||
|
||||
@module.launch_task
|
||||
async def load_workouts(client):
|
||||
client.objects['current_workouts'] = {} # (guildid, userid) -> Row
|
||||
# Process any incomplete workouts
|
||||
workouts = workout_sessions.fetch_rows_where(
|
||||
duration=NULL,
|
||||
guildid=THIS_SHARD
|
||||
)
|
||||
count = 0
|
||||
for workout in workouts:
|
||||
channelids = admin.workout_channels_setting.get(workout.guildid).data
|
||||
member = Lion.fetch(workout.guildid, workout.userid).member
|
||||
if member:
|
||||
if member.voice and (member.voice.channel.id in channelids):
|
||||
client.objects['current_workouts'][(workout.guildid, workout.userid)] = workout
|
||||
count += 1
|
||||
else:
|
||||
asyncio.create_task(workout_left(member, workout))
|
||||
else:
|
||||
client.log(
|
||||
"Removing incomplete workout from "
|
||||
"non-existent member (mid:{}) in guild (gid:{})".format(
|
||||
workout.userid,
|
||||
workout.guildid
|
||||
),
|
||||
context="WORKOUT_LAUNCH",
|
||||
post=True
|
||||
)
|
||||
if count > 0:
|
||||
client.log(
|
||||
"Loaded {} in-progress workouts.".format(count), context="WORKOUT_LAUNCH", post=True
|
||||
)
|
||||
Reference in New Issue
Block a user