Merge pull request #3 from StudyLions/staging

Bugfixes and minor UI improvements
This commit is contained in:
Interitio
2021-10-26 09:04:23 +03:00
committed by GitHub
15 changed files with 201 additions and 41 deletions

View File

@@ -1,4 +1,9 @@
from cmdClient import Command, Module import asyncio
import traceback
import logging
import discord
from cmdClient import Command, Module, FailedCheck
from cmdClient.lib import SafeCancellation from cmdClient.lib import SafeCancellation
from meta import log from meta import log
@@ -79,3 +84,87 @@ class LionModule(Module):
# Check guild's own member blacklist # Check guild's own member blacklist
if ctx.author.id in ctx.client.objects['ignored_members'][ctx.guild.id]: if ctx.author.id in ctx.client.objects['ignored_members'][ctx.guild.id]:
raise SafeCancellation raise SafeCancellation
# Check channel permissions are sane
if not ctx.ch.permissions_for(ctx.guild.me).send_messages:
raise SafeCancellation
if not ctx.ch.permissions_for(ctx.guild.me).embed_links:
await ctx.reply("I need permission to send embeds in this channel before I can run any commands!")
raise SafeCancellation
# Start typing
await ctx.ch.trigger_typing()
async def on_exception(self, ctx, exception):
try:
raise exception
except (FailedCheck, SafeCancellation):
# cmdClient generated and handled exceptions
raise exception
except (asyncio.CancelledError, asyncio.TimeoutError):
# Standard command and task exceptions, cmdClient will also handle these
raise exception
except discord.Forbidden:
# Unknown uncaught Forbidden
try:
# Attempt a general error reply
await ctx.reply("I don't have enough channel or server permissions to complete that command here!")
except discord.Forbidden:
# We can't send anything at all. Exit quietly, but log.
full_traceback = traceback.format_exc()
log(("Caught an unhandled 'Forbidden' while "
"executing command '{cmdname}' from module '{module}' "
"from user '{message.author}' (uid:{message.author.id}) "
"in guild '{message.guild}' (gid:{guildid}) "
"in channel '{message.channel}' (cid:{message.channel.id}).\n"
"Message Content:\n"
"{content}\n"
"{traceback}\n\n"
"{flat_ctx}").format(
cmdname=ctx.cmd.name,
module=ctx.cmd.module.name,
message=ctx.msg,
guildid=ctx.guild.id if ctx.guild else None,
content='\n'.join('\t' + line for line in ctx.msg.content.splitlines()),
traceback=full_traceback,
flat_ctx=ctx.flatten()
),
context="mid:{}".format(ctx.msg.id),
level=logging.WARNING)
except Exception as e:
# Unknown exception!
full_traceback = traceback.format_exc()
only_error = "".join(traceback.TracebackException.from_exception(e).format_exception_only())
log(("Caught an unhandled exception while "
"executing command '{cmdname}' from module '{module}' "
"from user '{message.author}' (uid:{message.author.id}) "
"in guild '{message.guild}' (gid:{guildid}) "
"in channel '{message.channel}' (cid:{message.channel.id}).\n"
"Message Content:\n"
"{content}\n"
"{traceback}\n\n"
"{flat_ctx}").format(
cmdname=ctx.cmd.name,
module=ctx.cmd.module.name,
message=ctx.msg,
guildid=ctx.guild.id if ctx.guild else None,
content='\n'.join('\t' + line for line in ctx.msg.content.splitlines()),
traceback=full_traceback,
flat_ctx=ctx.flatten()
),
context="mid:{}".format(ctx.msg.id),
level=logging.ERROR)
error_embed = discord.Embed(title="Something went wrong!")
error_embed.description = (
"An unexpected error occurred while processing your command!\n"
"Our development team has been notified, and the issue should be fixed soon.\n"
)
if logging.getLogger().getEffectiveLevel() < logging.INFO:
error_embed.add_field(
name="Exception",
value="`{}`".format(only_error)
)
await ctx.reply(embed=error_embed)

View File

@@ -10,3 +10,4 @@ from .reminders import *
from .renting import * from .renting import *
from .moderation import * from .moderation import *
from .accountability import * from .accountability import *
from .plugins import *

View File

@@ -409,12 +409,13 @@ class TimeSlot:
if self.channel: if self.channel:
try: try:
await self.channel.delete() await self.channel.delete()
self.channel = None
except discord.HTTPException: except discord.HTTPException:
pass pass
if self.message: if self.message:
try: try:
timestamp = self.start_time.timestamp() timestamp = int(self.start_time.timestamp())
embed = discord.Embed( embed = discord.Embed(
title="Session <t:{}:t> - <t:{}:t>".format( title="Session <t:{}:t> - <t:{}:t>".format(
timestamp, timestamp + 3600 timestamp, timestamp + 3600

View File

@@ -1,3 +1,4 @@
import asyncio
import discord import discord
import settings import settings
@@ -37,12 +38,12 @@ class accountability_category(settings.Channel, settings.GuildSetting):
return "The accountability system has been started in **{}**.".format(self.value.name) return "The accountability system has been started in **{}**.".format(self.value.name)
else: else:
if self.id in AG.cache: if self.id in AG.cache:
aguild = AG.cache[self.id] aguild = AG.cache.pop(self.id)
if aguild.current_slot: if aguild.current_slot:
aguild.current_lost.cancel() asyncio.create_task(aguild.current_slot.cancel())
if aguild.upcoming_slot: if aguild.upcoming_slot:
aguild.upcoming_slot.cancel() asyncio.create_task(aguild.upcoming_slot.cancel())
return "The accountability system has been stopped." return "The accountability system has been shut down."
else: else:
return "The accountability category has been unset." return "The accountability category has been unset."

View File

@@ -26,7 +26,7 @@ async def cmd_send(ctx):
# Extract target and amount # Extract target and amount
# Handle a slightly more flexible input than stated # Handle a slightly more flexible input than stated
splits = ctx.args.split() splits = ctx.args.split()
digits = [split.isdigit() for split in splits] digits = [split.isdigit() for split in splits[:2]]
mentions = ctx.msg.mentions mentions = ctx.msg.mentions
if len(splits) < 2 or not any(digits) or not (all(digits) or mentions): if len(splits) < 2 or not any(digits) or not (all(digits) or mentions):
return await _send_usage(ctx) return await _send_usage(ctx)

View File

@@ -1,6 +1,7 @@
import discord import discord
from cmdClient.lib import SafeCancellation
from wards import guild_admin from wards import guild_admin, guild_moderator
from settings import UserInputError, GuildSettings from settings import UserInputError, GuildSettings
from utils.lib import prop_tabulate from utils.lib import prop_tabulate
@@ -26,12 +27,40 @@ descriptions = {
desc="View and modify the server settings.", desc="View and modify the server settings.",
flags=('add', 'remove'), flags=('add', 'remove'),
group="Guild Configuration") group="Guild Configuration")
@guild_admin() @guild_moderator()
async def cmd_config(ctx, flags): async def cmd_config(ctx, flags):
"""
Usage``:
{prefix}config
{prefix}config info
{prefix}config <setting>
{prefix}config <setting> <value>
Description:
Display the server configuration panel, and view/modify the server settings.
Use `{prefix}config` to see the settings with their current values, or `{prefix}config info` to \
show brief descriptions instead.
Use `{prefix}config <setting>` (e.g. `{prefix}config event_log`) to view a more detailed description for each setting, \
including the possible values.
Finally, use `{prefix}config <setting> <value>` to set the setting to the given value.
To unset a setting, or set it to the default, use `{prefix}config <setting> None`.
Additional usage for settings which accept a list of values:
`{prefix}config <setting> <value1>, <value2>, ...`
`{prefix}config <setting> --add <value1>, <value2>, ...`
`{prefix}config <setting> --remove <value1>, <value2>, ...`
Note that the first form *overwrites* the setting completely,\
while the second two will only *add* and *remove* values, respectively.
Examples``:
{prefix}config event_log
{prefix}config event_log {ctx.ch.name}
{prefix}config autoroles Member, Level 0, Level 10
{prefix}config autoroles --remove Level 10
"""
# Cache and map some info for faster access # Cache and map some info for faster access
setting_displaynames = {setting.display_name.lower(): setting for setting in GuildSettings.settings.values()} setting_displaynames = {setting.display_name.lower(): setting for setting in GuildSettings.settings.values()}
if not ctx.args or ctx.args.lower() == 'help': if not ctx.args or ctx.args.lower() in ('info', 'help'):
# Fill the setting cats # Fill the setting cats
cats = {} cats = {}
for setting in GuildSettings.settings.values(): for setting in GuildSettings.settings.values():
@@ -60,8 +89,14 @@ async def cmd_config(ctx, flags):
colour=discord.Colour.orange(), colour=discord.Colour.orange(),
title=page_name, title=page_name,
description=( description=(
"View brief setting descriptions with `{prefix}config help`.\n" "View brief setting descriptions with `{prefix}config info`.\n"
"See `{prefix}help config` for more general usage.".format(prefix=ctx.best_prefix) "Use e.g. `{prefix}config event_log` to see more details.\n"
"Modify a setting with e.g. `{prefix}config event_log {ctx.ch.name}`.\n"
"See the [Online Tutorial]({tutorial}) for a complete setup guide.".format(
prefix=ctx.best_prefix,
ctx=ctx,
tutorial="https://discord.studylions.com/tutorial"
)
) )
) )
for name, value in page.items(): for name, value in page.items():
@@ -71,7 +106,7 @@ async def cmd_config(ctx, flags):
if len(pages) > 1: if len(pages) > 1:
[ [
embed.set_footer(text="Page {}/{}".format(i+1, len(pages))) embed.set_footer(text="Page {} of {}".format(i+1, len(pages)))
for i, embed in enumerate(pages) for i, embed in enumerate(pages)
] ]
await ctx.pager(pages) await ctx.pager(pages)
@@ -98,9 +133,12 @@ async def cmd_config(ctx, flags):
await setting.get(ctx.guild.id).widget(ctx, flags=flags) await setting.get(ctx.guild.id).widget(ctx, flags=flags)
else: else:
# config <setting> <value> # config <setting> <value>
# Ignoring the write ward currently and just enforcing admin
# Check the write ward # Check the write ward
if not await setting.write_ward.run(ctx): # if not await setting.write_ward.run(ctx):
await ctx.error_reply(setting.write_ward.msg) # raise SafeCancellation(setting.write_ward.msg)
if not await guild_admin.run(ctx):
raise SafeCancellation("You need to be a server admin to modify settings!")
# Attempt to set config setting # Attempt to set config setting
try: try:

View File

@@ -732,7 +732,8 @@ async def cmd_reactionroles(ctx, flags):
# Add the reactions to the message, if possible # Add the reactions to the message, if possible
existing_reactions = set( existing_reactions = set(
reaction.emoji.name if reaction.emoji.id is None else reaction.emoji.id reaction.emoji if not reaction.custom_emoji else
(reaction.emoji.name if reaction.emoji.id is None else reaction.emoji.id)
for reaction in message.reactions for reaction in message.reactions
) )
missing = [ missing = [

View File

@@ -40,7 +40,7 @@ def schedule_expiry(guildid, userid, roleid, expiry, reactionid=None):
_wakeup_event.set() _wakeup_event.set()
def cancel_expiry(key): def cancel_expiry(*key):
""" """
Cancel expiry for the given member and role, if it exists. Cancel expiry for the given member and role, if it exists.
""" """

View File

@@ -546,7 +546,7 @@ class ReactionRoleMessage:
@client.add_after_event('raw_reaction_add') @client.add_after_event('raw_reaction_add')
async def reaction_role_add(client, payload): async def reaction_role_add(client, payload):
reaction_message = ReactionRoleMessage.fetch(payload.message_id) reaction_message = ReactionRoleMessage.fetch(payload.message_id)
if not payload.member.bot and reaction_message and reaction_message.enabled: if payload.guild_id and not payload.member.bot and reaction_message and reaction_message.enabled:
try: try:
await reaction_message.process_raw_reaction_add(payload) await reaction_message.process_raw_reaction_add(payload)
except Exception: except Exception:
@@ -564,7 +564,7 @@ async def reaction_role_add(client, payload):
@client.add_after_event('raw_reaction_remove') @client.add_after_event('raw_reaction_remove')
async def reaction_role_remove(client, payload): async def reaction_role_remove(client, payload):
reaction_message = ReactionRoleMessage.fetch(payload.message_id) reaction_message = ReactionRoleMessage.fetch(payload.message_id)
if reaction_message and reaction_message.enabled: if payload.guild_id and reaction_message and reaction_message.enabled:
try: try:
await reaction_message.process_raw_reaction_remove(payload) await reaction_message.process_raw_reaction_remove(payload)
except Exception: except Exception:

View File

@@ -42,7 +42,11 @@ bot_admin_group_order = (
# TODO: Add config fields for this # TODO: Add config fields for this
title = "StudyLion Command List" title = "StudyLion Command List"
header = """ header = """
Use `{ctx.best_prefix}help <command>` (e.g. `{ctx.best_prefix}help send`) to see how to use each command. [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](https://discord.studylions.com/tutorial) for a comprehensive tutorial.
""" """

0
bot/modules/plugins/.gitignore vendored Normal file
View File

View File

@@ -37,6 +37,15 @@ async def cmd_rent(ctx):
# Fetch the members' room, if it exists # Fetch the members' room, if it exists
room = Room.fetch(ctx.guild.id, ctx.author.id) room = Room.fetch(ctx.guild.id, ctx.author.id)
# Handle pre-deletion of the room
if room and not room.channel:
ctx.guild_settings.event_log.log(
title="Private study room not found!",
description="{}'s study room was deleted before it expired!".format(ctx.author.mention)
)
room.delete()
room = None
if room: if room:
# Show room status, or add/remove remebers # Show room status, or add/remove remebers
lower = ctx.args.lower() lower = ctx.args.lower()

View File

@@ -112,7 +112,8 @@ class Room:
@property @property
def owner(self): def owner(self):
""" """
The Member owning the room, if we can find them The Member owning the room.
May be `None` if the member is no longer in the guild, or is otherwise not visible.
""" """
guild = client.get_guild(self.data.guildid) guild = client.get_guild(self.data.guildid)
if guild: if guild:
@@ -122,6 +123,7 @@ class Room:
def channel(self): def channel(self):
""" """
The Channel corresponding to this rented room. The Channel corresponding to this rented room.
May be `None` if the channel was already deleted.
""" """
guild = client.get_guild(self.data.guildid) guild = client.get_guild(self.data.guildid)
if guild: if guild:
@@ -176,9 +178,6 @@ class Room:
""" """
Expire the room. Expire the room.
""" """
owner = self.owner
guild_settings = GuildSettings(owner.guild.id)
if self.channel: if self.channel:
# Delete the discord channel # Delete the discord channel
try: try:
@@ -189,9 +188,10 @@ class Room:
# Delete the room from data (cascades to member deletion) # Delete the room from data (cascades to member deletion)
self.delete() self.delete()
guild_settings = GuildSettings(self.data.guildid)
guild_settings.event_log.log( guild_settings.event_log.log(
title="Private study room expired!", title="Private study room expired!",
description="{}'s private study room expired.".format(owner.mention) description="<@{}>'s private study room expired.".format(self.data.ownerid)
) )
async def add_members(self, *members): async def add_members(self, *members):

View File

@@ -1,6 +1,7 @@
import json import json
import asyncio import asyncio
import itertools import itertools
import traceback
from io import StringIO from io import StringIO
from enum import IntEnum from enum import IntEnum
from typing import Any, Optional from typing import Any, Optional
@@ -753,11 +754,19 @@ class Message(SettingType):
if as_json: if as_json:
try: try:
args = json.loads(userstr) args = json.loads(userstr)
except json.JSONDecodeError: if not isinstance(args, dict) or (not args.get('content', None) and not args.get('embed', None)):
raise ValueError("At least one of the 'content' or 'embed' data fields are required.")
if 'embed' in args:
discord.Embed.from_dict(
args['embed']
)
except Exception as e:
only_error = "".join(traceback.TracebackException.from_exception(e).format_exception_only())
raise UserInputError( raise UserInputError(
"Couldn't parse your message! " "Couldn't parse your message! "
"You can test and fix it on the embed builder " "You can test and fix it on the embed builder "
"[here](https://glitchii.github.io/embedbuilder/?editor=json)." "[here](https://glitchii.github.io/embedbuilder/?editor=json).\n"
"```{}```".format(only_error)
) )
if 'embed' in args and 'timestamp' in args['embed']: if 'embed' in args and 'timestamp' in args['embed']:
args['embed'].pop('timestamp') args['embed'].pop('timestamp')
@@ -770,6 +779,8 @@ class Message(SettingType):
if data is None: if data is None:
return "Empty" return "Empty"
value = cls._data_to_value(id, data, **kwargs) value = cls._data_to_value(id, data, **kwargs)
if 'embed' not in value and 'content' not in value:
return "Invalid"
if 'embed' not in value and len(value['content']) < 100: if 'embed' not in value and len(value['content']) < 100:
return "`{}`".format(value['content']) return "`{}`".format(value['content'])
else: else:
@@ -788,9 +799,9 @@ class Message(SettingType):
value = self.value value = self.value
substitutions = self.substitution_keys(ctx, **kwargs) substitutions = self.substitution_keys(ctx, **kwargs)
args = {} args = {}
if 'content' in value: if value.get('content', None):
args['content'] = multiple_replace(value['content'], substitutions) args['content'] = multiple_replace(value['content'], substitutions)
if 'embed' in value: if value.get('embed', None):
args['embed'] = discord.Embed.from_dict( args['embed'] = discord.Embed.from_dict(
json.loads(multiple_replace(json.dumps(value['embed']), substitutions)) json.loads(multiple_replace(json.dumps(value['embed']), substitutions))
) )
@@ -800,7 +811,7 @@ class Message(SettingType):
value = self.value value = self.value
args = self.args(ctx, **kwargs) args = self.args(ctx, **kwargs)
if not value: if not value or not args:
return await ctx.reply(embed=self.embed) return await ctx.reply(embed=self.embed)
current_str = None current_str = None

View File

@@ -1,6 +1,7 @@
import asyncio import asyncio
import discord import discord
from cmdClient import Context from cmdClient import Context
from cmdClient.lib import SafeCancellation
from data import tables from data import tables
from core import Lion from core import Lion
@@ -17,9 +18,11 @@ async def embed_reply(ctx, desc, colour=discord.Colour.orange(), **kwargs):
""" """
embed = discord.Embed(description=desc, colour=colour, **kwargs) embed = discord.Embed(description=desc, colour=colour, **kwargs)
try: try:
return await ctx.reply(embed=embed, reference=ctx.msg) return await ctx.reply(embed=embed, reference=ctx.msg.to_reference(fail_if_not_exists=False))
except discord.NotFound: except discord.Forbidden:
return await ctx.reply(embed=embed) if not ctx.guild or ctx.ch.permissions_for(ctx.guild.me).send_mssages:
await ctx.reply("Command failed, I don't have permission to send embeds in this channel!")
raise SafeCancellation
@Context.util @Context.util
@@ -34,15 +37,17 @@ async def error_reply(ctx, error_str, **kwargs):
) )
message = None message = None
try: try:
message = await ctx.ch.send(embed=embed, reference=ctx.msg, **kwargs) message = await ctx.ch.send(
except discord.NotFound: embed=embed,
message = await ctx.ch.send(embed=embed, **kwargs) reference=ctx.msg.to_reference(fail_if_not_exists=False),
**kwargs
)
ctx.sent_messages.append(message)
return message
except discord.Forbidden: except discord.Forbidden:
message = await ctx.reply(error_str) if not ctx.guild or ctx.ch.permissions_for(ctx.guild.me).send_mssages:
finally: await ctx.reply("Command failed, I don't have permission to send embeds in this channel!")
if message: raise SafeCancellation
ctx.sent_messages.append(message)
return message
@Context.util @Context.util