Implemented generic command error handling

Also:
- Removed emotes from some of the error messages.
- Changed how emotes were placed into the leaderboard slightly
- Moved around TODO strings into proper files and added more.
- Corrected main.py cog loading references.
- Improved ContestBot.reject with message references and used built-in
delete_after keyword argument.
- Minor docstring/light formatting
This commit is contained in:
Xevion
2021-02-18 06:07:40 -06:00
parent 8164d528a5
commit 1dc9c7d435
5 changed files with 90 additions and 34 deletions

View File

@@ -9,7 +9,7 @@ from discord.ext import commands
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from bot import constants from bot import constants, helpers
from bot.models import Guild, Period, Submission from bot.models import Guild, Period, Submission
logger = logging.getLogger(__file__) logger = logging.getLogger(__file__)
@@ -64,6 +64,8 @@ class ContestBot(commands.Bot):
f'Guild {guild.name} ({guild.id}) was not inside database on ready. Bot was disconnected or did not add it properly...') f'Guild {guild.name} ({guild.id}) was not inside database on ready. Bot was disconnected or did not add it properly...')
session.add(Guild(id=guild.id)) session.add(Guild(id=guild.id))
# TODO: Scan all messages on start for current period and check for new periods/updated vote counts.
async def on_guild_join(self, guild: discord.Guild) -> None: async def on_guild_join(self, guild: discord.Guild) -> None:
"""Handles adding or reactivating a Guild in the database.""" """Handles adding or reactivating a Guild in the database."""
logger.info(f'Added to new guild: {guild.name} ({guild.id})') logger.info(f'Added to new guild: {guild.name} ({guild.id})')
@@ -121,10 +123,7 @@ class ContestBot(commands.Bot):
return await channel.fetch_message(message_id) return await channel.fetch_message(message_id)
@staticmethod @staticmethod
async def reject(message: discord.Message, warning: str, delete_delay: int = 1, warning_duration: int = 5) -> None: async def reject(message: discord.Message, warning: str, delete_delay: int = 2, warning_duration: int = 5) -> None:
"""Send a warning message and delete the message, then the warning""" """Send a warning message and delete the message, then the warning"""
if delete_delay < 0: if delete_delay > 0: await message.delete(delay=delete_delay)
await message.delete(delay=delete_delay) await message.channel.send(embed=helpers.error_embed(message=warning), delete_after=warning_duration, reference=message)
warning = await message.channel.send(warning)
if warning_duration < 0:
await warning.delete(delay=warning_duration)

View File

@@ -1,4 +1,3 @@
import datetime
import logging import logging
import discord import discord
@@ -13,6 +12,8 @@ logger = logging.getLogger(__file__)
logger.setLevel(constants.LOGGING_LEVEL) logger.setLevel(constants.LOGGING_LEVEL)
# TODO: Add command error handling to all commands
class ContestCommandsCog(commands.Cog, name='Contest'): class ContestCommandsCog(commands.Cog, name='Contest'):
""" """
Commands related to creating, advancing, and querying contests. Commands related to creating, advancing, and querying contests.
@@ -21,6 +22,64 @@ class ContestCommandsCog(commands.Cog, name='Contest'):
def __init__(self, bot: ContestBot) -> None: def __init__(self, bot: ContestBot) -> None:
self.bot = bot self.bot = bot
@commands.Cog.listener()
async def on_command_error(self, ctx: Context, error: discord.ext.commands.CommandError):
"""
The event triggered when an error is raised while invoking a command.
Taken and slightly edited from https://gist.github.com/EvieePy/7822af90858ef65012ea500bcecf1612
:param error: The context used for command invocation.
:param ctx: The exception raised.
"""
# This prevents any commands with local handlers being handled here in on_command_error.
if hasattr(ctx.command, 'on_error'):
return
# This prevents any cogs with an overwritten cog_command_error being handled here.
cog = ctx.cog
if cog and cog._get_overridden_method(cog.cog_command_error) is not None:
return
ignored = (commands.CommandNotFound,)
# Allows us to check for original exceptions raised and sent to CommandInvokeError.
# If nothing is found. We keep the exception passed to on_command_error.
error = getattr(error, 'original', error)
# Anything in ignored will return and prevent anything happening.
if isinstance(error, ignored):
return
if isinstance(error, commands.UserInputError):
message = ''
if isinstance(error, commands.BadArgument):
if isinstance(error, commands.ChannelNotFound):
message = 'Invalid channel - I couldn\'t find that channel.'
elif isinstance(error, commands.RoleNotFound):
message = 'Invalid role - I couldn\'t find that role'
elif isinstance(error, commands.ChannelNotReadable):
message = 'Invalid channel - I couldn\'t read the contents of that channel.'
else:
message = 'Invalid argument. Please check you entered everything correctly.'
if isinstance(error, commands.ArgumentParsingError):
message = 'I couldn\'t read the contents of your arguments properly. Check that you\'ve entered everything properly.'
if message:
await ctx.send(embed=helpers.error_embed(message=message))
return
if isinstance(error, commands.DisabledCommand):
await ctx.send(embed=helpers.error_embed(message=f'`{ctx.command}` has been disabled.'))
elif isinstance(error, commands.NoPrivateMessage):
try:
await ctx.send(embed=helpers.error_embed(message=f'`{ctx.command}` can not be used in Private Messages.'))
except discord.HTTPException:
pass
else:
# All other Errors not returned come here. And we can just print the default TraceBack.
logger.warning(f'Ignoring exception in command {ctx.command}', exc_info=error)
@commands.command() @commands.command()
@commands.guild_only() @commands.guild_only()
@checks.privileged() @checks.privileged()
@@ -69,10 +128,11 @@ class ContestCommandsCog(commands.Cog, name='Contest'):
""" """
Advance the state of the current period pertaining to this Guild. Advance the state of the current period pertaining to this Guild.
:param ctx: :param ctx: The context used for command invocation.
:param duration: If given, the advance command will be repeated once more after the duration (in seconds) has passed. :param duration: If given, the advance command will be repeated once more after the duration (in seconds) has passed.
:param pingback: Whether or not the user should be pinged back when the duration is passed. :param pingback: Whether or not the user should be pinged back when the duration is passed.
""" """
# TODO: Implement pingback argument
# TODO: Ensure that permissions for this command are being correctly tested for. # TODO: Ensure that permissions for this command are being correctly tested for.
if duration is not None: assert duration >= 0, "If specified, duration must be more than or equal to zero." if duration is not None: assert duration >= 0, "If specified, duration must be more than or equal to zero."
@@ -129,6 +189,12 @@ class ContestCommandsCog(commands.Cog, name='Contest'):
@advance.error @advance.error
async def advance_error(self, error: errors.CommandError, ctx: Context) -> None: async def advance_error(self, error: errors.CommandError, ctx: Context) -> None:
"""
`advance` command error handling.
:param error: The error raised while attempting to invoke the command
:param ctx: The context used for command invocation.
"""
if isinstance(error, errors.MissingPermissions): if isinstance(error, errors.MissingPermissions):
await ctx.send(embed=helpers.error_embed( await ctx.send(embed=helpers.error_embed(
message='Check that the bot can actually modify roles, add reactions, see messages and send messages within this channel.')) message='Check that the bot can actually modify roles, add reactions, see messages and send messages within this channel.'))
@@ -195,22 +261,18 @@ class ContestCommandsCog(commands.Cog, name='Contest'):
message = self.bot.get_message(guild.submission_channel, submission.id) message = self.bot.get_message(guild.submission_channel, submission.id)
emote = '' emote = ''
if i == 1: emote = ':trophy: ' if i == 1: emote = ':trophy:'
elif i == 2: emote = ':second_place: ' elif i == 2: emote = ':second_place:'
elif i == 3: emote = ':third_place: ' elif i == 3: emote = ':third_place:'
description += f'`{str(i).zfill(2)}` {emote}<@{submission.user}> [Jump]({message.jump_url})\n' description += f'`{str(i).zfill(2)}` {emote + " " if emote else ""}<@{submission.user}> [Jump]({message.jump_url})\n'
if not description: if not description:
description = 'No one has submitted anything yet.' description = 'No one has submitted anything yet.'
embed = discord.Embed(title='Leaderboard', embed = helpers.general_embed(title='Leaderboard', message=description, timestamp=True)
color=constants.GENERAL_COLOR,
description=description,
timestamp=datetime.datetime.utcnow())
embed.set_footer(text='Contest is still in progress...' if guild.current_period.active else 'Contest has finished.') embed.set_footer(text='Contest is still in progress...' if guild.current_period.active else 'Contest has finished.')
# embed.add_field(name="🤔", value="some of these properties have certain limits...", inline=True)
await ctx.send(embed=embed) await ctx.send(embed=embed)

View File

@@ -12,11 +12,7 @@ logger = logging.getLogger(__file__)
logger.setLevel(constants.LOGGING_LEVEL) logger.setLevel(constants.LOGGING_LEVEL)
# TODO: Add command error handling to all commands
# TODO: Use embeds in all bot responses
# TODO: Look into migrating from literals to i18n-ish representation of all messages & formatting # TODO: Look into migrating from literals to i18n-ish representation of all messages & formatting
# TODO: Contest names
# TODO: Refactor Period into Contest (major)
class ContestEventsCog(commands.Cog): class ContestEventsCog(commands.Cog):
@@ -38,15 +34,12 @@ class ContestEventsCog(commands.Cog):
# Ensure that the submission contains at least one attachment # Ensure that the submission contains at least one attachment
if len(attachments) == 0: if len(attachments) == 0:
await self.bot.reject(message, f':no_entry_sign: {message.author.mention} ' await self.bot.reject(message, f'Each submission must contain exactly one image.')
f'Each submission must contain exactly one image.')
# Ensure the image contains no more than one attachment # Ensure the image contains no more than one attachment
elif len(attachments) > 1: elif len(attachments) > 1:
await self.bot.reject(message, f':no_entry_sign: {message.author.mention} ' await self.bot.reject(message, f'Each submission must contain exactly one image.')
f'Each submission must contain exactly one image.')
elif guild.current_period is None: elif guild.current_period is None:
await self.bot.reject(message, f':no_entry_sign: {message.author.mention} A period has not been started. ' await self.bot.reject(message, f'A period has not been started. Submissions should not be allowed at this moment.')
f'Submissions should not be allowed at this moment.')
elif guild.current_period != PeriodStates.SUBMISSIONS: elif guild.current_period != PeriodStates.SUBMISSIONS:
logger.warning(f'Valid submission was sent outside of Submissions in' logger.warning(f'Valid submission was sent outside of Submissions in'
f' {channel.id}/{message.id}. Permissions error? Removing.') f' {channel.id}/{message.id}. Permissions error? Removing.')
@@ -55,9 +48,9 @@ class ContestEventsCog(commands.Cog):
attachment = attachments[0] attachment = attachments[0]
# TODO: Add helper for displaying error/warning messages # TODO: Add helper for displaying error/warning messages
if attachment.is_spoiler(): if attachment.is_spoiler():
await self.bot.reject(message, ':no_entry_sign: Attachment must not make use of a spoiler.') await self.bot.reject(message, 'Attachment must not make use of a spoiler.')
elif attachment.width is None: elif attachment.width is None:
await self.bot.reject(message, ':no_entry_sign: Attachment must be a image or video.') await self.bot.reject(message, 'Attachment must be a image or video.')
else: else:
last_submission: Submission = session.query(Submission).filter_by(period=guild.current_period, last_submission: Submission = session.query(Submission).filter_by(period=guild.current_period,
user=message.author.id).first() user=message.author.id).first()
@@ -157,6 +150,8 @@ class ContestEventsCog(commands.Cog):
except ValueError: except ValueError:
pass pass
# TODO: Only update the votecount during the VOTING period!
with self.bot.get_session() as session: with self.bot.get_session() as session:
guild: Guild = session.query(Guild).get(payload.guild_id) guild: Guild = session.query(Guild).get(payload.guild_id)
if payload.channel_id == guild.submission_channel and helpers.is_upvote(payload.emoji): if payload.channel_id == guild.submission_channel and helpers.is_upvote(payload.emoji):

View File

@@ -23,9 +23,8 @@ logger.setLevel(constants.LOGGING_LEVEL)
Base = declarative_base() Base = declarative_base()
# TODO: Contest names
# TODO: Setup and test basic automatic migration. # TODO: Refactor Period into Contest (major)
class PeriodStates(enum.Enum): class PeriodStates(enum.Enum):
""" """

View File

@@ -25,7 +25,8 @@ if __name__ == "__main__":
logging.StreamHandler() logging.StreamHandler()
]) ])
initial_extensions = ['bot.cogs.contest'] initial_extensions = ['bot.cogs.contest_commands',
'bot.cogs.contest_events']
engine = load_db() engine = load_db()
bot = ContestBot(engine, description='A assistant for the Photography Lounge\'s monday contests') bot = ContestBot(engine, description='A assistant for the Photography Lounge\'s monday contests')