From 42e47bea1a2606ee4ee0f1f89ba292c2d5ea6621 Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 20 Oct 2021 12:55:36 +0300 Subject: [PATCH 01/10] fix (rooms): Cancel rooms on system shutdown. Fix typos in the `success_response` for `accountability_category`. Make the `TimeSlot.cancel()` method more robust. --- bot/modules/accountability/TimeSlot.py | 3 ++- bot/modules/accountability/admin.py | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/bot/modules/accountability/TimeSlot.py b/bot/modules/accountability/TimeSlot.py index b87a0df3..ac12efea 100644 --- a/bot/modules/accountability/TimeSlot.py +++ b/bot/modules/accountability/TimeSlot.py @@ -409,12 +409,13 @@ class TimeSlot: if self.channel: try: await self.channel.delete() + self.channel = None except discord.HTTPException: pass if self.message: try: - timestamp = self.start_time.timestamp() + timestamp = int(self.start_time.timestamp()) embed = discord.Embed( title="Session - ".format( timestamp, timestamp + 3600 diff --git a/bot/modules/accountability/admin.py b/bot/modules/accountability/admin.py index cc1bd9d2..e294feef 100644 --- a/bot/modules/accountability/admin.py +++ b/bot/modules/accountability/admin.py @@ -1,3 +1,4 @@ +import asyncio import discord 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) else: if self.id in AG.cache: - aguild = AG.cache[self.id] + aguild = AG.cache.pop(self.id) if aguild.current_slot: - aguild.current_lost.cancel() + asyncio.create_task(aguild.current_slot.cancel()) if aguild.upcoming_slot: - aguild.upcoming_slot.cancel() - return "The accountability system has been stopped." + asyncio.create_task(aguild.upcoming_slot.cancel()) + return "The accountability system has been shut down." else: return "The accountability category has been unset." From c275b8b095fd695e7fffea293ff97c1447544f10 Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 20 Oct 2021 13:26:39 +0300 Subject: [PATCH 02/10] (LionModule): Add custom exception handler. Provide more context when logging errors, and a friendlier message. --- bot/LionModule.py | 81 +++++++++++++++++++++++++++++++++- bot/modules/__init__.py | 1 + bot/modules/plugins/.gitignore | 0 3 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 bot/modules/plugins/.gitignore diff --git a/bot/LionModule.py b/bot/LionModule.py index 18bcb233..f234eb01 100644 --- a/bot/LionModule.py +++ b/bot/LionModule.py @@ -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 meta import log @@ -79,3 +84,77 @@ class LionModule(Module): # Check guild's own member blacklist if ctx.author.id in ctx.client.objects['ignored_members'][ctx.guild.id]: raise SafeCancellation + + 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) diff --git a/bot/modules/__init__.py b/bot/modules/__init__.py index 01da8814..9a6a51ae 100644 --- a/bot/modules/__init__.py +++ b/bot/modules/__init__.py @@ -10,3 +10,4 @@ from .reminders import * from .renting import * from .moderation import * from .accountability import * +from .plugins import * diff --git a/bot/modules/plugins/.gitignore b/bot/modules/plugins/.gitignore new file mode 100644 index 00000000..e69de29b From 66fdf54ca8c9f7755e0c555a405bf6661cf71758 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 24 Oct 2021 18:50:14 +0300 Subject: [PATCH 03/10] (core): Improve permission error handling. Add channel permission wards to `LionModule` pre-command hook. Improve `Forbidden` handling in `embed_reply` and `error_reply` addons. --- bot/LionModule.py | 10 ++++++++++ bot/utils/ctx_addons.py | 27 ++++++++++++++++----------- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/bot/LionModule.py b/bot/LionModule.py index f234eb01..2d6fb854 100644 --- a/bot/LionModule.py +++ b/bot/LionModule.py @@ -85,6 +85,16 @@ class LionModule(Module): if ctx.author.id in ctx.client.objects['ignored_members'][ctx.guild.id]: 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 diff --git a/bot/utils/ctx_addons.py b/bot/utils/ctx_addons.py index e88ca1b8..931422d9 100644 --- a/bot/utils/ctx_addons.py +++ b/bot/utils/ctx_addons.py @@ -1,6 +1,7 @@ import asyncio import discord from cmdClient import Context +from cmdClient.lib import SafeCancellation from data import tables 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) try: - return await ctx.reply(embed=embed, reference=ctx.msg) - except discord.NotFound: - return await ctx.reply(embed=embed) + return await ctx.reply(embed=embed, reference=ctx.msg.to_reference(fail_if_not_exists=False)) + except discord.Forbidden: + 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 @@ -34,15 +37,17 @@ async def error_reply(ctx, error_str, **kwargs): ) message = None try: - message = await ctx.ch.send(embed=embed, reference=ctx.msg, **kwargs) - except discord.NotFound: - message = await ctx.ch.send(embed=embed, **kwargs) + message = await ctx.ch.send( + embed=embed, + reference=ctx.msg.to_reference(fail_if_not_exists=False), + **kwargs + ) + ctx.sent_messages.append(message) + return message except discord.Forbidden: - message = await ctx.reply(error_str) - finally: - if message: - ctx.sent_messages.append(message) - return message + 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 From d619b0fe17948457ef617b635d84281d63ac5b4d Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 24 Oct 2021 19:39:06 +0300 Subject: [PATCH 04/10] fix (rroles): Fix tracker issues. Make `cancel_expiry` arguments consistent with call. Add initial `guild` ward to the raw reaction events. --- bot/modules/guild_admin/reaction_roles/expiry.py | 2 +- bot/modules/guild_admin/reaction_roles/tracker.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/modules/guild_admin/reaction_roles/expiry.py b/bot/modules/guild_admin/reaction_roles/expiry.py index 2624c49d..f928cd74 100644 --- a/bot/modules/guild_admin/reaction_roles/expiry.py +++ b/bot/modules/guild_admin/reaction_roles/expiry.py @@ -40,7 +40,7 @@ def schedule_expiry(guildid, userid, roleid, expiry, reactionid=None): _wakeup_event.set() -def cancel_expiry(key): +def cancel_expiry(*key): """ Cancel expiry for the given member and role, if it exists. """ diff --git a/bot/modules/guild_admin/reaction_roles/tracker.py b/bot/modules/guild_admin/reaction_roles/tracker.py index baf232ba..6d130263 100644 --- a/bot/modules/guild_admin/reaction_roles/tracker.py +++ b/bot/modules/guild_admin/reaction_roles/tracker.py @@ -546,7 +546,7 @@ class ReactionRoleMessage: @client.add_after_event('raw_reaction_add') async def reaction_role_add(client, payload): 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: await reaction_message.process_raw_reaction_add(payload) except Exception: @@ -564,7 +564,7 @@ async def reaction_role_add(client, payload): @client.add_after_event('raw_reaction_remove') async def reaction_role_remove(client, payload): 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: await reaction_message.process_raw_reaction_remove(payload) except Exception: From 9b8c952e78c83e3ca1b5b725b9b0149c5e2d6fff Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 24 Oct 2021 19:52:06 +0300 Subject: [PATCH 05/10] fix (rroles): Handle unicode emojis on creation. Fixes a rroles creation issues with pre-existing unicode emojis. --- bot/modules/guild_admin/reaction_roles/command.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/modules/guild_admin/reaction_roles/command.py b/bot/modules/guild_admin/reaction_roles/command.py index c89de8e1..61e4a1ed 100644 --- a/bot/modules/guild_admin/reaction_roles/command.py +++ b/bot/modules/guild_admin/reaction_roles/command.py @@ -732,7 +732,8 @@ async def cmd_reactionroles(ctx, flags): # Add the reactions to the message, if possible 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 ) missing = [ From 5ad4ec5ee11e2e5685e6467a8b2eacefb5870bf4 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 24 Oct 2021 20:12:28 +0300 Subject: [PATCH 06/10] fix (send cmd): Harden arg parsing. Fix an issue where the send arg parsing would pass non-matching input. --- bot/modules/economy/send_cmd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/modules/economy/send_cmd.py b/bot/modules/economy/send_cmd.py index cf5ebd64..5086ee67 100644 --- a/bot/modules/economy/send_cmd.py +++ b/bot/modules/economy/send_cmd.py @@ -26,7 +26,7 @@ async def cmd_send(ctx): # Extract target and amount # Handle a slightly more flexible input than stated splits = ctx.args.split() - digits = [split.isdigit() for split in splits] + digits = [split.isdigit() for split in splits[:2]] mentions = ctx.msg.mentions if len(splits) < 2 or not any(digits) or not (all(digits) or mentions): return await _send_usage(ctx) From e9c812b65a17c0b6f35ed424ff066a3bdab97780 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 24 Oct 2021 20:48:37 +0300 Subject: [PATCH 07/10] fix (Message): Harden parsing and display. Significantly broaden error handling for initial parsing. Add error details upon parsing error. Add more error catchers to parser and formatter. Remove assumptions about data fields from format and output. --- bot/settings/setting_types.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/bot/settings/setting_types.py b/bot/settings/setting_types.py index 25a63faa..74a64622 100644 --- a/bot/settings/setting_types.py +++ b/bot/settings/setting_types.py @@ -1,6 +1,7 @@ import json import asyncio import itertools +import traceback from io import StringIO from enum import IntEnum from typing import Any, Optional @@ -753,11 +754,19 @@ class Message(SettingType): if as_json: try: 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( "Couldn't parse your message! " "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']: args['embed'].pop('timestamp') @@ -770,6 +779,8 @@ class Message(SettingType): if data is None: return "Empty" 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: return "`{}`".format(value['content']) else: @@ -788,9 +799,9 @@ class Message(SettingType): value = self.value substitutions = self.substitution_keys(ctx, **kwargs) args = {} - if 'content' in value: + if value.get('content', None): args['content'] = multiple_replace(value['content'], substitutions) - if 'embed' in value: + if value.get('embed', None): args['embed'] = discord.Embed.from_dict( json.loads(multiple_replace(json.dumps(value['embed']), substitutions)) ) @@ -800,7 +811,7 @@ class Message(SettingType): value = self.value args = self.args(ctx, **kwargs) - if not value: + if not value or not args: return await ctx.reply(embed=self.embed) current_str = None From cf610ef44d454f9f22ea6c94673a3d99e013d5f0 Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 25 Oct 2021 14:37:38 +0300 Subject: [PATCH 08/10] fix (rent): Handle non-existent objects. Handle room channel being deleted before expiry. Handle room owner leaving the server before expiry. --- bot/modules/renting/commands.py | 9 +++++++++ bot/modules/renting/rooms.py | 10 +++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/bot/modules/renting/commands.py b/bot/modules/renting/commands.py index ccaaece2..b94c01f5 100644 --- a/bot/modules/renting/commands.py +++ b/bot/modules/renting/commands.py @@ -37,6 +37,15 @@ async def cmd_rent(ctx): # Fetch the members' room, if it exists 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: # Show room status, or add/remove remebers lower = ctx.args.lower() diff --git a/bot/modules/renting/rooms.py b/bot/modules/renting/rooms.py index a4c7ce47..c83adc29 100644 --- a/bot/modules/renting/rooms.py +++ b/bot/modules/renting/rooms.py @@ -112,7 +112,8 @@ class Room: @property 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) if guild: @@ -122,6 +123,7 @@ class Room: def channel(self): """ The Channel corresponding to this rented room. + May be `None` if the channel was already deleted. """ guild = client.get_guild(self.data.guildid) if guild: @@ -176,9 +178,6 @@ class Room: """ Expire the room. """ - owner = self.owner - guild_settings = GuildSettings(owner.guild.id) - if self.channel: # Delete the discord channel try: @@ -189,9 +188,10 @@ class Room: # Delete the room from data (cascades to member deletion) self.delete() + guild_settings = GuildSettings(self.data.guildid) guild_settings.event_log.log( 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): From 17d6f103459af4929c38794f271e1f9faf5cbef7 Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 25 Oct 2021 15:00:09 +0300 Subject: [PATCH 09/10] UI (help): Add flavour and links to `help`. --- bot/modules/meta/help.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/modules/meta/help.py b/bot/modules/meta/help.py index 2c7bb899..d49b12ed 100644 --- a/bot/modules/meta/help.py +++ b/bot/modules/meta/help.py @@ -42,7 +42,11 @@ bot_admin_group_order = ( # TODO: Add config fields for this title = "StudyLion Command List" header = """ -Use `{ctx.best_prefix}help ` (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 ` (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. """ From f18b7c2702c7fe283ead74e47de57ceead9a7142 Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 25 Oct 2021 16:15:43 +0300 Subject: [PATCH 10/10] docs (config): Add more documentation. Write `config` command docs. Extend `config` header and add tutorial link. Allow moderators to read settings (but not configure them). Change `config help` to `config info`. --- bot/modules/guild_admin/guild_config.py | 54 +++++++++++++++++++++---- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/bot/modules/guild_admin/guild_config.py b/bot/modules/guild_admin/guild_config.py index 21f81baf..608786fb 100644 --- a/bot/modules/guild_admin/guild_config.py +++ b/bot/modules/guild_admin/guild_config.py @@ -1,6 +1,7 @@ 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 utils.lib import prop_tabulate @@ -26,12 +27,40 @@ descriptions = { desc="View and modify the server settings.", flags=('add', 'remove'), group="Guild Configuration") -@guild_admin() +@guild_moderator() async def cmd_config(ctx, flags): + """ + Usage``: + {prefix}config + {prefix}config info + {prefix}config + {prefix}config + 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 ` (e.g. `{prefix}config event_log`) to view a more detailed description for each setting, \ + including the possible values. + Finally, use `{prefix}config ` to set the setting to the given value. + To unset a setting, or set it to the default, use `{prefix}config None`. + + Additional usage for settings which accept a list of values: + `{prefix}config , , ...` + `{prefix}config --add , , ...` + `{prefix}config --remove , , ...` + 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 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 cats = {} for setting in GuildSettings.settings.values(): @@ -60,8 +89,14 @@ async def cmd_config(ctx, flags): colour=discord.Colour.orange(), title=page_name, description=( - "View brief setting descriptions with `{prefix}config help`.\n" - "See `{prefix}help config` for more general usage.".format(prefix=ctx.best_prefix) + "View brief setting descriptions with `{prefix}config info`.\n" + "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(): @@ -71,7 +106,7 @@ async def cmd_config(ctx, flags): 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) ] await ctx.pager(pages) @@ -98,9 +133,12 @@ async def cmd_config(ctx, flags): await setting.get(ctx.guild.id).widget(ctx, flags=flags) else: # config + # Ignoring the write ward currently and just enforcing admin # Check the write ward - if not await setting.write_ward.run(ctx): - await ctx.error_reply(setting.write_ward.msg) + # if not await setting.write_ward.run(ctx): + # 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 try: