diff --git a/bot/LionModule.py b/bot/LionModule.py index 18bcb233..2d6fb854 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,87 @@ class LionModule(Module): # Check guild's own member blacklist 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 + 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/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." 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) 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: 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 = [ 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: 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. """ diff --git a/bot/modules/plugins/.gitignore b/bot/modules/plugins/.gitignore new file mode 100644 index 00000000..e69de29b 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): 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 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