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