aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Sebastiaan Zeeff <[email protected]>2020-02-29 13:24:24 +0100
committerGravatar GitHub <[email protected]>2020-02-29 13:24:24 +0100
commitee73d45b3995a4e25109570f1ab0292c7783f3e1 (patch)
treeb4054c0e052ec4839f3a1cc06b42f8ecd01c646a
parentScheduler: correct type annotations (diff)
parentMerge pull request #797 from Numerlor/fuzzy_zero_div (diff)
Merge branch 'master' into bug/backend/b754/scheduler-suppresses-errors
-rw-r--r--bot/__main__.py11
-rw-r--r--bot/cogs/antimalware.py10
-rw-r--r--bot/cogs/bot.py5
-rw-r--r--bot/cogs/clean.py2
-rw-r--r--bot/cogs/config_verifier.py40
-rw-r--r--bot/cogs/defcon.py14
-rw-r--r--bot/cogs/error_handler.py258
-rw-r--r--bot/cogs/eval.py4
-rw-r--r--bot/cogs/extensions.py2
-rw-r--r--bot/cogs/free.py2
-rw-r--r--bot/cogs/help.py2
-rw-r--r--bot/cogs/information.py6
-rw-r--r--bot/cogs/jams.py6
-rw-r--r--bot/cogs/logging.py2
-rw-r--r--bot/cogs/moderation/infractions.py2
-rw-r--r--bot/cogs/moderation/management.py4
-rw-r--r--bot/cogs/moderation/modlog.py22
-rw-r--r--bot/cogs/moderation/scheduler.py2
-rw-r--r--bot/cogs/reddit.py9
-rw-r--r--bot/cogs/snekbox.py135
-rw-r--r--bot/cogs/sync/syncers.py8
-rw-r--r--bot/cogs/tags.py5
-rw-r--r--bot/cogs/utils.py2
-rw-r--r--bot/cogs/verification.py11
-rw-r--r--bot/constants.py57
-rw-r--r--bot/converters.py26
-rw-r--r--bot/utils/__init__.py12
-rw-r--r--config-default.yml219
-rw-r--r--tests/bot/cogs/sync/test_base.py4
-rw-r--r--tests/bot/cogs/test_information.py10
-rw-r--r--tests/bot/cogs/test_snekbox.py368
-rw-r--r--tests/bot/test_converters.py2
-rw-r--r--tests/bot/test_utils.py15
-rw-r--r--tests/helpers.py12
34 files changed, 936 insertions, 353 deletions
diff --git a/bot/__main__.py b/bot/__main__.py
index 490163739..3df477a6d 100644
--- a/bot/__main__.py
+++ b/bot/__main__.py
@@ -7,10 +7,10 @@ from sentry_sdk.integrations.logging import LoggingIntegration
from bot import patches
from bot.bot import Bot
-from bot.constants import Bot as BotConfig, DEBUG_MODE
+from bot.constants import Bot as BotConfig
sentry_logging = LoggingIntegration(
- level=logging.TRACE,
+ level=logging.DEBUG,
event_level=logging.WARNING
)
@@ -31,6 +31,7 @@ bot.load_extension("bot.cogs.error_handler")
bot.load_extension("bot.cogs.filtering")
bot.load_extension("bot.cogs.logging")
bot.load_extension("bot.cogs.security")
+bot.load_extension("bot.cogs.config_verifier")
# Commands, etc
bot.load_extension("bot.cogs.antimalware")
@@ -40,10 +41,8 @@ bot.load_extension("bot.cogs.clean")
bot.load_extension("bot.cogs.extensions")
bot.load_extension("bot.cogs.help")
-# Only load this in production
-if not DEBUG_MODE:
- bot.load_extension("bot.cogs.doc")
- bot.load_extension("bot.cogs.verification")
+bot.load_extension("bot.cogs.doc")
+bot.load_extension("bot.cogs.verification")
# Feature cogs
bot.load_extension("bot.cogs.alias")
diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py
index 28e3e5d96..9e9e81364 100644
--- a/bot/cogs/antimalware.py
+++ b/bot/cogs/antimalware.py
@@ -4,7 +4,7 @@ from discord import Embed, Message, NotFound
from discord.ext.commands import Cog
from bot.bot import Bot
-from bot.constants import AntiMalware as AntiMalwareConfig, Channels, URLs
+from bot.constants import AntiMalware as AntiMalwareConfig, Channels, STAFF_ROLES, URLs
log = logging.getLogger(__name__)
@@ -18,7 +18,13 @@ class AntiMalware(Cog):
@Cog.listener()
async def on_message(self, message: Message) -> None:
"""Identify messages with prohibited attachments."""
- if not message.attachments:
+ # Return when message don't have attachment and don't moderate DMs
+ if not message.attachments or not message.guild:
+ return
+
+ # Check if user is staff, if is, return
+ # Since we only care that roles exist to iterate over, check for the attr rather than a User/Member instance
+ if hasattr(message.author, "roles") and any(role.id in STAFF_ROLES for role in message.author.roles):
return
embed = Embed()
diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py
index 73b1e8f41..f17135877 100644
--- a/bot/cogs/bot.py
+++ b/bot/cogs/bot.py
@@ -34,13 +34,12 @@ class BotCog(Cog, name="Bot"):
Channels.help_5: 0,
Channels.help_6: 0,
Channels.help_7: 0,
- Channels.python: 0,
+ Channels.python_discussion: 0,
}
# These channels will also work, but will not be subject to cooldown
self.channel_whitelist = (
- Channels.bot,
- Channels.devtest,
+ Channels.bot_commands,
)
# Stores improperly formatted Python codeblock message ids and the corresponding bot message
diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py
index 2104efe57..5cdf0b048 100644
--- a/bot/cogs/clean.py
+++ b/bot/cogs/clean.py
@@ -173,7 +173,7 @@ class Clean(Cog):
colour=Colour(Colours.soft_red),
title="Bulk message delete",
text=message,
- channel_id=Channels.modlog,
+ channel_id=Channels.mod_log,
)
@group(invoke_without_command=True, name="clean", aliases=["purge"])
diff --git a/bot/cogs/config_verifier.py b/bot/cogs/config_verifier.py
new file mode 100644
index 000000000..d72c6c22e
--- /dev/null
+++ b/bot/cogs/config_verifier.py
@@ -0,0 +1,40 @@
+import logging
+
+from discord.ext.commands import Cog
+
+from bot import constants
+from bot.bot import Bot
+
+
+log = logging.getLogger(__name__)
+
+
+class ConfigVerifier(Cog):
+ """Verify config on startup."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self.channel_verify_task = self.bot.loop.create_task(self.verify_channels())
+
+ async def verify_channels(self) -> None:
+ """
+ Verify channels.
+
+ If any channels in config aren't present in server, log them in a warning.
+ """
+ await self.bot.wait_until_guild_available()
+ server = self.bot.get_guild(constants.Guild.id)
+
+ server_channel_ids = {channel.id for channel in server.channels}
+ invalid_channels = [
+ channel_name for channel_name, channel_id in constants.Channels
+ if channel_id not in server_channel_ids
+ ]
+
+ if invalid_channels:
+ log.warning(f"Configured channels do not exist in server: {', '.join(invalid_channels)}.")
+
+
+def setup(bot: Bot) -> None:
+ """Load the ConfigVerifier cog."""
+ bot.add_cog(ConfigVerifier(bot))
diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py
index 20961e0a2..cc0f79fe8 100644
--- a/bot/cogs/defcon.py
+++ b/bot/cogs/defcon.py
@@ -68,8 +68,8 @@ class Defcon(Cog):
except Exception: # Yikes!
log.exception("Unable to get DEFCON settings!")
- await self.bot.get_channel(Channels.devlog).send(
- f"<@&{Roles.admin}> **WARNING**: Unable to get DEFCON settings!"
+ await self.bot.get_channel(Channels.dev_log).send(
+ f"<@&{Roles.admins}> **WARNING**: Unable to get DEFCON settings!"
)
else:
@@ -118,7 +118,7 @@ class Defcon(Cog):
)
@group(name='defcon', aliases=('dc',), invoke_without_command=True)
- @with_role(Roles.admin, Roles.owner)
+ @with_role(Roles.admins, Roles.owners)
async def defcon_group(self, ctx: Context) -> None:
"""Check the DEFCON status or run a subcommand."""
await ctx.invoke(self.bot.get_command("help"), "defcon")
@@ -146,7 +146,7 @@ class Defcon(Cog):
await self.send_defcon_log(action, ctx.author, error)
@defcon_group.command(name='enable', aliases=('on', 'e'))
- @with_role(Roles.admin, Roles.owner)
+ @with_role(Roles.admins, Roles.owners)
async def enable_command(self, ctx: Context) -> None:
"""
Enable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!
@@ -159,7 +159,7 @@ class Defcon(Cog):
await self.update_channel_topic()
@defcon_group.command(name='disable', aliases=('off', 'd'))
- @with_role(Roles.admin, Roles.owner)
+ @with_role(Roles.admins, Roles.owners)
async def disable_command(self, ctx: Context) -> None:
"""Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!"""
self.enabled = False
@@ -167,7 +167,7 @@ class Defcon(Cog):
await self.update_channel_topic()
@defcon_group.command(name='status', aliases=('s',))
- @with_role(Roles.admin, Roles.owner)
+ @with_role(Roles.admins, Roles.owners)
async def status_command(self, ctx: Context) -> None:
"""Check the current status of DEFCON mode."""
embed = Embed(
@@ -179,7 +179,7 @@ class Defcon(Cog):
await ctx.send(embed=embed)
@defcon_group.command(name='days')
- @with_role(Roles.admin, Roles.owner)
+ @with_role(Roles.admins, Roles.owners)
async def days_command(self, ctx: Context, days: int) -> None:
"""Set how old an account must be to join the server, in days, with DEFCON mode enabled."""
self.days = timedelta(days=days)
diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py
index 0abb7e521..261769efc 100644
--- a/bot/cogs/error_handler.py
+++ b/bot/cogs/error_handler.py
@@ -1,25 +1,14 @@
import contextlib
import logging
+import typing as t
-from discord.ext.commands import (
- BadArgument,
- BotMissingPermissions,
- CheckFailure,
- CommandError,
- CommandInvokeError,
- CommandNotFound,
- CommandOnCooldown,
- DisabledCommand,
- MissingPermissions,
- NoPrivateMessage,
- UserInputError,
-)
-from discord.ext.commands import Cog, Context
+from discord.ext.commands import Cog, Command, Context, errors
from sentry_sdk import push_scope
from bot.api import ResponseCodeError
from bot.bot import Bot
from bot.constants import Channels
+from bot.converters import TagNameConverter
from bot.decorators import InChannelCheckFailure
log = logging.getLogger(__name__)
@@ -32,118 +21,185 @@ class ErrorHandler(Cog):
self.bot = bot
@Cog.listener()
- async def on_command_error(self, ctx: Context, e: CommandError) -> None:
+ async def on_command_error(self, ctx: Context, e: errors.CommandError) -> None:
"""
Provide generic command error handling.
- Error handling is deferred to any local error handler, if present.
-
- Error handling emits a single error response, prioritized as follows:
- 1. If the name fails to match a command but matches a tag, the tag is invoked
- 2. Send a BadArgument error message to the invoking context & invoke the command's help
- 3. Send a UserInputError error message to the invoking context & invoke the command's help
- 4. Send a NoPrivateMessage error message to the invoking context
- 5. Send a BotMissingPermissions error message to the invoking context
- 6. Log a MissingPermissions error, no message is sent
- 7. Send a InChannelCheckFailure error message to the invoking context
- 8. Log CheckFailure, CommandOnCooldown, and DisabledCommand errors, no message is sent
- 9. For CommandInvokeErrors, response is based on the type of error:
- * 404: Error message is sent to the invoking context
- * 400: Log the resopnse JSON, no message is sent
- * 500 <= status <= 600: Error message is sent to the invoking context
- 10. Otherwise, handling is deferred to `handle_unexpected_error`
+ Error handling is deferred to any local error handler, if present. This is done by
+ checking for the presence of a `handled` attribute on the error.
+
+ Error handling emits a single error message in the invoking context `ctx` and a log message,
+ prioritised as follows:
+
+ 1. If the name fails to match a command but matches a tag, the tag is invoked
+ * If CommandNotFound is raised when invoking the tag (determined by the presence of the
+ `invoked_from_error_handler` attribute), this error is treated as being unexpected
+ and therefore sends an error message
+ * Commands in the verification channel are ignored
+ 2. UserInputError: see `handle_user_input_error`
+ 3. CheckFailure: see `handle_check_failure`
+ 4. CommandOnCooldown: send an error message in the invoking context
+ 5. ResponseCodeError: see `handle_api_error`
+ 6. Otherwise, if not a DisabledCommand, handling is deferred to `handle_unexpected_error`
"""
command = ctx.command
- parent = None
+ if hasattr(e, "handled"):
+ log.trace(f"Command {command} had its error already handled locally; ignoring.")
+ return
+
+ # Try to look for a tag with the command's name if the command isn't found.
+ if isinstance(e, errors.CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"):
+ if ctx.channel.id != Channels.verification:
+ await self.try_get_tag(ctx)
+ return # Exit early to avoid logging.
+ elif isinstance(e, errors.UserInputError):
+ await self.handle_user_input_error(ctx, e)
+ elif isinstance(e, errors.CheckFailure):
+ await self.handle_check_failure(ctx, e)
+ elif isinstance(e, errors.CommandOnCooldown):
+ await ctx.send(e)
+ elif isinstance(e, errors.CommandInvokeError):
+ if isinstance(e.original, ResponseCodeError):
+ await self.handle_api_error(ctx, e.original)
+ else:
+ await self.handle_unexpected_error(ctx, e.original)
+ return # Exit early to avoid logging.
+ elif not isinstance(e, errors.DisabledCommand):
+ # ConversionError, MaxConcurrencyReached, ExtensionError
+ await self.handle_unexpected_error(ctx, e)
+ return # Exit early to avoid logging.
+
+ log.debug(
+ f"Command {command} invoked by {ctx.message.author} with error "
+ f"{e.__class__.__name__}: {e}"
+ )
+
+ async def get_help_command(self, command: t.Optional[Command]) -> t.Tuple:
+ """Return the help command invocation args to display help for `command`."""
+ parent = None
if command is not None:
parent = command.parent
# Retrieve the help command for the invoked command.
if parent and command:
- help_command = (self.bot.get_command("help"), parent.name, command.name)
+ return self.bot.get_command("help"), parent.name, command.name
elif command:
- help_command = (self.bot.get_command("help"), command.name)
+ return self.bot.get_command("help"), command.name
else:
- help_command = (self.bot.get_command("help"),)
+ return self.bot.get_command("help")
- if hasattr(e, "handled"):
- log.trace(f"Command {command} had its error already handled locally; ignoring.")
+ async def try_get_tag(self, ctx: Context) -> None:
+ """
+ Attempt to display a tag by interpreting the command name as a tag name.
+
+ The invocation of tags get respects its checks. Any CommandErrors raised will be handled
+ by `on_command_error`, but the `invoked_from_error_handler` attribute will be added to
+ the context to prevent infinite recursion in the case of a CommandNotFound exception.
+ """
+ tags_get_command = self.bot.get_command("tags get")
+ ctx.invoked_from_error_handler = True
+
+ log_msg = "Cancelling attempt to fall back to a tag due to failed checks."
+ try:
+ if not await tags_get_command.can_run(ctx):
+ log.debug(log_msg)
+ return
+ except errors.CommandError as tag_error:
+ log.debug(log_msg)
+ await self.on_command_error(ctx, tag_error)
return
- # Try to look for a tag with the command's name if the command isn't found.
- if isinstance(e, CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"):
- if not ctx.channel.id == Channels.verification:
- tags_get_command = self.bot.get_command("tags get")
- ctx.invoked_from_error_handler = True
-
- log_msg = "Cancelling attempt to fall back to a tag due to failed checks."
- try:
- if not await tags_get_command.can_run(ctx):
- log.debug(log_msg)
- return
- except CommandError as tag_error:
- log.debug(log_msg)
- await self.on_command_error(ctx, tag_error)
- return
-
- # Return to not raise the exception
- with contextlib.suppress(ResponseCodeError):
- await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with)
- return
- elif isinstance(e, BadArgument):
+ try:
+ tag_name = await TagNameConverter.convert(ctx, ctx.invoked_with)
+ except errors.BadArgument:
+ log.debug(
+ f"{ctx.author} tried to use an invalid command "
+ f"and the fallback tag failed validation in TagNameConverter."
+ )
+ else:
+ with contextlib.suppress(ResponseCodeError):
+ await ctx.invoke(tags_get_command, tag_name=tag_name)
+ # Return to not raise the exception
+ return
+
+ async def handle_user_input_error(self, ctx: Context, e: errors.UserInputError) -> None:
+ """
+ Send an error message in `ctx` for UserInputError, sometimes invoking the help command too.
+
+ * MissingRequiredArgument: send an error message with arg name and the help command
+ * TooManyArguments: send an error message and the help command
+ * BadArgument: send an error message and the help command
+ * BadUnionArgument: send an error message including the error produced by the last converter
+ * ArgumentParsingError: send an error message
+ * Other: send an error message and the help command
+ """
+ # TODO: use ctx.send_help() once PR #519 is merged.
+ help_command = await self.get_help_command(ctx.command)
+
+ if isinstance(e, errors.MissingRequiredArgument):
+ await ctx.send(f"Missing required argument `{e.param.name}`.")
+ await ctx.invoke(*help_command)
+ elif isinstance(e, errors.TooManyArguments):
+ await ctx.send(f"Too many arguments provided.")
+ await ctx.invoke(*help_command)
+ elif isinstance(e, errors.BadArgument):
await ctx.send(f"Bad argument: {e}\n")
await ctx.invoke(*help_command)
- elif isinstance(e, UserInputError):
+ elif isinstance(e, errors.BadUnionArgument):
+ await ctx.send(f"Bad argument: {e}\n```{e.errors[-1]}```")
+ elif isinstance(e, errors.ArgumentParsingError):
+ await ctx.send(f"Argument parsing error: {e}")
+ else:
await ctx.send("Something about your input seems off. Check the arguments:")
await ctx.invoke(*help_command)
- log.debug(
- f"Command {command} invoked by {ctx.message.author} with error "
- f"{e.__class__.__name__}: {e}"
- )
- elif isinstance(e, NoPrivateMessage):
- await ctx.send("Sorry, this command can't be used in a private message!")
- elif isinstance(e, BotMissingPermissions):
- await ctx.send(f"Sorry, it looks like I don't have the permissions I need to do that.")
- log.warning(
- f"The bot is missing permissions to execute command {command}: {e.missing_perms}"
- )
- elif isinstance(e, MissingPermissions):
- log.debug(
- f"{ctx.message.author} is missing permissions to invoke command {command}: "
- f"{e.missing_perms}"
+
+ @staticmethod
+ async def handle_check_failure(ctx: Context, e: errors.CheckFailure) -> None:
+ """
+ Send an error message in `ctx` for certain types of CheckFailure.
+
+ The following types are handled:
+
+ * BotMissingPermissions
+ * BotMissingRole
+ * BotMissingAnyRole
+ * NoPrivateMessage
+ * InChannelCheckFailure
+ """
+ bot_missing_errors = (
+ errors.BotMissingPermissions,
+ errors.BotMissingRole,
+ errors.BotMissingAnyRole
+ )
+
+ if isinstance(e, bot_missing_errors):
+ await ctx.send(
+ f"Sorry, it looks like I don't have the permissions or roles I need to do that."
)
- elif isinstance(e, InChannelCheckFailure):
+ elif isinstance(e, (InChannelCheckFailure, errors.NoPrivateMessage)):
await ctx.send(e)
- elif isinstance(e, (CheckFailure, CommandOnCooldown, DisabledCommand)):
- log.debug(
- f"Command {command} invoked by {ctx.message.author} with error "
- f"{e.__class__.__name__}: {e}"
- )
- elif isinstance(e, CommandInvokeError):
- if isinstance(e.original, ResponseCodeError):
- status = e.original.response.status
-
- if status == 404:
- await ctx.send("There does not seem to be anything matching your query.")
- elif status == 400:
- content = await e.original.response.json()
- log.debug(f"API responded with 400 for command {command}: %r.", content)
- await ctx.send("According to the API, your request is malformed.")
- elif 500 <= status < 600:
- await ctx.send("Sorry, there seems to be an internal issue with the API.")
- log.warning(f"API responded with {status} for command {command}")
- else:
- await ctx.send(f"Got an unexpected status code from the API (`{status}`).")
- log.warning(f"Unexpected API response for command {command}: {status}")
- else:
- await self.handle_unexpected_error(ctx, e.original)
+
+ @staticmethod
+ async def handle_api_error(ctx: Context, e: ResponseCodeError) -> None:
+ """Send an error message in `ctx` for ResponseCodeError and log it."""
+ if e.status == 404:
+ await ctx.send("There does not seem to be anything matching your query.")
+ log.debug(f"API responded with 404 for command {ctx.command}")
+ elif e.status == 400:
+ content = await e.response.json()
+ log.debug(f"API responded with 400 for command {ctx.command}: %r.", content)
+ await ctx.send("According to the API, your request is malformed.")
+ elif 500 <= e.status < 600:
+ await ctx.send("Sorry, there seems to be an internal issue with the API.")
+ log.warning(f"API responded with {e.status} for command {ctx.command}")
else:
- await self.handle_unexpected_error(ctx, e)
+ await ctx.send(f"Got an unexpected status code from the API (`{e.status}`).")
+ log.warning(f"Unexpected API response for command {ctx.command}: {e.status}")
@staticmethod
- async def handle_unexpected_error(ctx: Context, e: CommandError) -> None:
- """Generic handler for errors without an explicit handler."""
+ async def handle_unexpected_error(ctx: Context, e: errors.CommandError) -> None:
+ """Send a generic error message in `ctx` and log the exception as an error with exc_info."""
await ctx.send(
f"Sorry, an unexpected error occurred. Please let us know!\n\n"
f"```{e.__class__.__name__}: {e}```"
diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py
index 9c729f28a..52136fc8d 100644
--- a/bot/cogs/eval.py
+++ b/bot/cogs/eval.py
@@ -174,14 +174,14 @@ async def func(): # (None,) -> Any
await ctx.send(f"```py\n{out}```", embed=embed)
@group(name='internal', aliases=('int',))
- @with_role(Roles.owner, Roles.admin)
+ @with_role(Roles.owners, Roles.admins)
async def internal_group(self, ctx: Context) -> None:
"""Internal commands. Top secret!"""
if not ctx.invoked_subcommand:
await ctx.invoke(self.bot.get_command("help"), "internal")
@internal_group.command(name='eval', aliases=('e',))
- @with_role(Roles.admin, Roles.owner)
+ @with_role(Roles.admins, Roles.owners)
async def eval(self, ctx: Context, *, code: str) -> None:
"""Run eval in a REPL-like format."""
code = code.strip("`")
diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py
index f16e79fb7..b312e1a1d 100644
--- a/bot/cogs/extensions.py
+++ b/bot/cogs/extensions.py
@@ -221,7 +221,7 @@ class Extensions(commands.Cog):
# This cannot be static (must have a __func__ attribute).
def cog_check(self, ctx: Context) -> bool:
"""Only allow moderators and core developers to invoke the commands in this cog."""
- return with_role_check(ctx, *MODERATION_ROLES, Roles.core_developer)
+ return with_role_check(ctx, *MODERATION_ROLES, Roles.core_developers)
# This cannot be static (must have a __func__ attribute).
async def cog_command_error(self, ctx: Context, error: Exception) -> None:
diff --git a/bot/cogs/free.py b/bot/cogs/free.py
index 49cab6172..02c02d067 100644
--- a/bot/cogs/free.py
+++ b/bot/cogs/free.py
@@ -22,7 +22,7 @@ class Free(Cog):
PYTHON_HELP_ID = Categories.python_help
@command(name="free", aliases=('f',))
- @redirect_output(destination_channel=Channels.bot, bypass_roles=STAFF_ROLES)
+ @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_ROLES)
async def free(self, ctx: Context, user: Member = None, seek: int = 2) -> None:
"""
Lists free help channels by likeliness of availability.
diff --git a/bot/cogs/help.py b/bot/cogs/help.py
index fd5bbc3ca..744722220 100644
--- a/bot/cogs/help.py
+++ b/bot/cogs/help.py
@@ -507,7 +507,7 @@ class Help(DiscordCog):
"""Custom Embed Pagination Help feature."""
@commands.command('help')
- @redirect_output(destination_channel=Channels.bot, bypass_roles=STAFF_ROLES)
+ @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_ROLES)
async def new_help(self, ctx: Context, *commands) -> None:
"""Shows Command Help."""
try:
diff --git a/bot/cogs/information.py b/bot/cogs/information.py
index 13c8aabaa..49beca15b 100644
--- a/bot/cogs/information.py
+++ b/bot/cogs/information.py
@@ -152,8 +152,8 @@ class Information(Cog):
# Non-staff may only do this in #bot-commands
if not with_role_check(ctx, *constants.STAFF_ROLES):
- if not ctx.channel.id == constants.Channels.bot:
- raise InChannelCheckFailure(constants.Channels.bot)
+ if not ctx.channel.id == constants.Channels.bot_commands:
+ raise InChannelCheckFailure(constants.Channels.bot_commands)
embed = await self.create_user_embed(ctx, user)
@@ -332,7 +332,7 @@ class Information(Cog):
@cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES)
@group(invoke_without_command=True)
- @in_channel(constants.Channels.bot, bypass_roles=constants.STAFF_ROLES)
+ @in_channel(constants.Channels.bot_commands, bypass_roles=constants.STAFF_ROLES)
async def raw(self, ctx: Context, *, message: Message, json: bool = False) -> None:
"""Shows information about the raw API response."""
# I *guess* it could be deleted right as the command is invoked but I felt like it wasn't worth handling
diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py
index 985f28ce5..1d062b0c2 100644
--- a/bot/cogs/jams.py
+++ b/bot/cogs/jams.py
@@ -18,7 +18,7 @@ class CodeJams(commands.Cog):
self.bot = bot
@commands.command()
- @with_role(Roles.admin)
+ @with_role(Roles.admins)
async def createteam(self, ctx: commands.Context, team_name: str, members: commands.Greedy[Member]) -> None:
"""
Create team channels (voice and text) in the Code Jams category, assign roles, and add overwrites for the team.
@@ -95,10 +95,10 @@ class CodeJams(commands.Cog):
)
# Assign team leader role
- await members[0].add_roles(ctx.guild.get_role(Roles.team_leader))
+ await members[0].add_roles(ctx.guild.get_role(Roles.team_leaders))
# Assign rest of roles
- jammer_role = ctx.guild.get_role(Roles.jammer)
+ jammer_role = ctx.guild.get_role(Roles.jammers)
for member in members:
await member.add_roles(jammer_role)
diff --git a/bot/cogs/logging.py b/bot/cogs/logging.py
index dbd76672f..94fa2b139 100644
--- a/bot/cogs/logging.py
+++ b/bot/cogs/logging.py
@@ -34,7 +34,7 @@ class Logging(Cog):
)
if not DEBUG_MODE:
- await self.bot.get_channel(Channels.devlog).send(embed=embed)
+ await self.bot.get_channel(Channels.dev_log).send(embed=embed)
def setup(bot: Bot) -> None:
diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py
index f4e296df9..9ea17b2b3 100644
--- a/bot/cogs/moderation/infractions.py
+++ b/bot/cogs/moderation/infractions.py
@@ -313,6 +313,6 @@ class Infractions(InfractionScheduler, commands.Cog):
async def cog_command_error(self, ctx: Context, error: Exception) -> None:
"""Send a notification to the invoking context on a Union failure."""
if isinstance(error, commands.BadUnionArgument):
- if discord.User in error.converters:
+ if discord.User in error.converters or discord.Member in error.converters:
await ctx.send(str(error.errors[0]))
error.handled = True
diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py
index 279c8b809..35448f682 100644
--- a/bot/cogs/moderation/management.py
+++ b/bot/cogs/moderation/management.py
@@ -128,7 +128,9 @@ class ModManagement(commands.Cog):
# Re-schedule infraction if the expiration has been updated
if 'expires_at' in request_data:
- self.infractions_cog.cancel_task(new_infraction['id'])
+ # A scheduled task should only exist if the old infraction wasn't permanent
+ if old_infraction['expires_at']:
+ self.infractions_cog.cancel_task(new_infraction['id'])
# If the infraction was not marked as permanent, schedule a new expiration task
if request_data['expires_at']:
diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py
index e8ae0dbe6..59ae6b587 100644
--- a/bot/cogs/moderation/modlog.py
+++ b/bot/cogs/moderation/modlog.py
@@ -87,7 +87,7 @@ class ModLog(Cog, name="ModLog"):
title: t.Optional[str],
text: str,
thumbnail: t.Optional[t.Union[str, discord.Asset]] = None,
- channel_id: int = Channels.modlog,
+ channel_id: int = Channels.mod_log,
ping_everyone: bool = False,
files: t.Optional[t.List[discord.File]] = None,
content: t.Optional[str] = None,
@@ -377,7 +377,7 @@ class ModLog(Cog, name="ModLog"):
Icons.user_ban, Colours.soft_red,
"User banned", f"{member} (`{member.id}`)",
thumbnail=member.avatar_url_as(static_format="png"),
- channel_id=Channels.userlog
+ channel_id=Channels.user_log
)
@Cog.listener()
@@ -399,7 +399,7 @@ class ModLog(Cog, name="ModLog"):
Icons.sign_in, Colours.soft_green,
"User joined", message,
thumbnail=member.avatar_url_as(static_format="png"),
- channel_id=Channels.userlog
+ channel_id=Channels.user_log
)
@Cog.listener()
@@ -416,7 +416,7 @@ class ModLog(Cog, name="ModLog"):
Icons.sign_out, Colours.soft_red,
"User left", f"{member} (`{member.id}`)",
thumbnail=member.avatar_url_as(static_format="png"),
- channel_id=Channels.userlog
+ channel_id=Channels.user_log
)
@Cog.listener()
@@ -433,7 +433,7 @@ class ModLog(Cog, name="ModLog"):
Icons.user_unban, Colour.blurple(),
"User unbanned", f"{member} (`{member.id}`)",
thumbnail=member.avatar_url_as(static_format="png"),
- channel_id=Channels.modlog
+ channel_id=Channels.mod_log
)
@Cog.listener()
@@ -529,7 +529,7 @@ class ModLog(Cog, name="ModLog"):
Icons.user_update, Colour.blurple(),
"Member updated", message,
thumbnail=after.avatar_url_as(static_format="png"),
- channel_id=Channels.userlog
+ channel_id=Channels.user_log
)
@Cog.listener()
@@ -538,7 +538,7 @@ class ModLog(Cog, name="ModLog"):
channel = message.channel
author = message.author
- if message.guild.id != GuildConstant.id or channel.id in GuildConstant.ignored:
+ if message.guild.id != GuildConstant.id or channel.id in GuildConstant.modlog_blacklist:
return
self._cached_deletes.append(message.id)
@@ -591,7 +591,7 @@ class ModLog(Cog, name="ModLog"):
@Cog.listener()
async def on_raw_message_delete(self, event: discord.RawMessageDeleteEvent) -> None:
"""Log raw message delete event to message change log."""
- if event.guild_id != GuildConstant.id or event.channel_id in GuildConstant.ignored:
+ if event.guild_id != GuildConstant.id or event.channel_id in GuildConstant.modlog_blacklist:
return
await asyncio.sleep(1) # Wait here in case the normal event was fired
@@ -635,7 +635,7 @@ class ModLog(Cog, name="ModLog"):
if (
not msg_before.guild
or msg_before.guild.id != GuildConstant.id
- or msg_before.channel.id in GuildConstant.ignored
+ or msg_before.channel.id in GuildConstant.modlog_blacklist
or msg_before.author.bot
):
return
@@ -717,7 +717,7 @@ class ModLog(Cog, name="ModLog"):
if (
not message.guild
or message.guild.id != GuildConstant.id
- or message.channel.id in GuildConstant.ignored
+ or message.channel.id in GuildConstant.modlog_blacklist
or message.author.bot
):
return
@@ -769,7 +769,7 @@ class ModLog(Cog, name="ModLog"):
"""Log member voice state changes to the voice log channel."""
if (
member.guild.id != GuildConstant.id
- or (before.channel and before.channel.id in GuildConstant.ignored)
+ or (before.channel and before.channel.id in GuildConstant.modlog_blacklist)
):
return
diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py
index 93afd9f9f..f0b6b2c48 100644
--- a/bot/cogs/moderation/scheduler.py
+++ b/bot/cogs/moderation/scheduler.py
@@ -308,7 +308,7 @@ class InfractionScheduler(Scheduler):
Infractions of unsupported types will raise a ValueError.
"""
guild = self.bot.get_guild(constants.Guild.id)
- mod_role = guild.get_role(constants.Roles.moderator)
+ mod_role = guild.get_role(constants.Roles.moderators)
user_id = infraction["user"]
actor = infraction["actor"]
type_ = infraction["type"]
diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py
index 4f6584aba..5a7fa100f 100644
--- a/bot/cogs/reddit.py
+++ b/bot/cogs/reddit.py
@@ -43,8 +43,8 @@ class Reddit(Cog):
def cog_unload(self) -> None:
"""Stop the loop task and revoke the access token when the cog is unloaded."""
self.auto_poster_loop.cancel()
- if self.access_token.expires_at < datetime.utcnow():
- self.revoke_access_token()
+ if self.access_token and self.access_token.expires_at > datetime.utcnow():
+ asyncio.create_task(self.revoke_access_token())
async def init_reddit_ready(self) -> None:
"""Sets the reddit webhook when the cog is loaded."""
@@ -83,7 +83,7 @@ class Reddit(Cog):
expires_at=datetime.utcnow() + timedelta(seconds=expiration)
)
- log.debug(f"New token acquired; expires on {self.access_token.expires_at}")
+ log.debug(f"New token acquired; expires on UTC {self.access_token.expires_at}")
return
else:
log.debug(
@@ -290,4 +290,7 @@ class Reddit(Cog):
def setup(bot: Bot) -> None:
"""Load the Reddit cog."""
+ if not RedditConfig.secret or not RedditConfig.client_id:
+ log.error("Credentials not provided, cog not loaded.")
+ return
bot.add_cog(Reddit(bot))
diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py
index da33e27b2..cff7c5786 100644
--- a/bot/cogs/snekbox.py
+++ b/bot/cogs/snekbox.py
@@ -1,10 +1,14 @@
+import asyncio
+import contextlib
import datetime
import logging
import re
import textwrap
+from functools import partial
from signal import Signals
from typing import Optional, Tuple
+from discord import HTTPException, Message, NotFound, Reaction, User
from discord.ext.commands import Cog, Context, command, guild_only
from bot.bot import Bot
@@ -34,7 +38,11 @@ RAW_CODE_REGEX = re.compile(
)
MAX_PASTE_LEN = 1000
-EVAL_ROLES = (Roles.helpers, Roles.moderator, Roles.admin, Roles.owner, Roles.rockstars, Roles.partners)
+EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community, Roles.partners)
+
+SIGKILL = 9
+
+REEVAL_EMOJI = '\U0001f501' # :repeat:
class Snekbox(Cog):
@@ -101,7 +109,7 @@ class Snekbox(Cog):
if returncode is None:
msg = "Your eval job has failed"
error = stdout.strip()
- elif returncode == 128 + Signals.SIGKILL:
+ elif returncode == 128 + SIGKILL:
msg = "Your eval job timed out or ran out of memory"
elif returncode == 255:
msg = "Your eval job has failed"
@@ -135,7 +143,7 @@ class Snekbox(Cog):
"""
log.trace("Formatting output...")
- output = output.strip(" \n")
+ output = output.rstrip("\n")
original_output = output # To be uploaded to a pasting service if needed
paste_link = None
@@ -152,8 +160,8 @@ class Snekbox(Cog):
lines = output.count("\n")
if lines > 0:
- output = output.split("\n")[:10] # Only first 10 cause the rest is truncated anyway
- output = (f"{i:03d} | {line}" for i, line in enumerate(output, 1))
+ output = [f"{i:03d} | {line}" for i, line in enumerate(output.split('\n'), 1)]
+ output = output[:11] # Limiting to only 11 lines
output = "\n".join(output)
if lines > 10:
@@ -169,21 +177,84 @@ class Snekbox(Cog):
if truncated:
paste_link = await self.upload_output(original_output)
- output = output.strip()
- if not output:
- output = "[No output]"
+ output = output or "[No output]"
return output, paste_link
+ async def send_eval(self, ctx: Context, code: str) -> Message:
+ """
+ Evaluate code, format it, and send the output to the corresponding channel.
+
+ Return the bot response.
+ """
+ async with ctx.typing():
+ results = await self.post_eval(code)
+ msg, error = self.get_results_message(results)
+
+ if error:
+ output, paste_link = error, None
+ else:
+ output, paste_link = await self.format_output(results["stdout"])
+
+ icon = self.get_status_emoji(results)
+ msg = f"{ctx.author.mention} {icon} {msg}.\n\n```py\n{output}\n```"
+ if paste_link:
+ msg = f"{msg}\nFull output: {paste_link}"
+
+ response = await ctx.send(msg)
+ self.bot.loop.create_task(
+ wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot)
+ )
+
+ log.info(f"{ctx.author}'s job had a return code of {results['returncode']}")
+ return response
+
+ async def continue_eval(self, ctx: Context, response: Message) -> Optional[str]:
+ """
+ Check if the eval session should continue.
+
+ Return the new code to evaluate or None if the eval session should be terminated.
+ """
+ _predicate_eval_message_edit = partial(predicate_eval_message_edit, ctx)
+ _predicate_emoji_reaction = partial(predicate_eval_emoji_reaction, ctx)
+
+ with contextlib.suppress(NotFound):
+ try:
+ _, new_message = await self.bot.wait_for(
+ 'message_edit',
+ check=_predicate_eval_message_edit,
+ timeout=10
+ )
+ await ctx.message.add_reaction(REEVAL_EMOJI)
+ await self.bot.wait_for(
+ 'reaction_add',
+ check=_predicate_emoji_reaction,
+ timeout=10
+ )
+
+ code = new_message.content.split(' ', maxsplit=1)[1]
+ await ctx.message.clear_reactions()
+ with contextlib.suppress(HTTPException):
+ await response.delete()
+
+ except asyncio.TimeoutError:
+ await ctx.message.clear_reactions()
+ return None
+
+ return code
+
@command(name="eval", aliases=("e",))
@guild_only()
- @in_channel(Channels.bot, hidden_channels=(Channels.esoteric,), bypass_roles=EVAL_ROLES)
+ @in_channel(Channels.bot_commands, hidden_channels=(Channels.esoteric,), bypass_roles=EVAL_ROLES)
async def eval_command(self, ctx: Context, *, code: str = None) -> None:
"""
Run Python code and get the results.
This command supports multiple lines of code, including code wrapped inside a formatted code
- block. We've done our best to make this safe, but do let us know if you manage to find an
+ block. Code can be re-evaluated by editing the original message within 10 seconds and
+ clicking the reaction that subsequently appears.
+
+ We've done our best to make this sandboxed, but do let us know if you manage to find an
issue with it!
"""
if ctx.author.id in self.jobs:
@@ -199,32 +270,28 @@ class Snekbox(Cog):
log.info(f"Received code from {ctx.author} for evaluation:\n{code}")
- self.jobs[ctx.author.id] = datetime.datetime.now()
- code = self.prepare_input(code)
+ while True:
+ self.jobs[ctx.author.id] = datetime.datetime.now()
+ code = self.prepare_input(code)
+ try:
+ response = await self.send_eval(ctx, code)
+ finally:
+ del self.jobs[ctx.author.id]
+
+ code = await self.continue_eval(ctx, response)
+ if not code:
+ break
+ log.info(f"Re-evaluating message {ctx.message.id}")
+
+
+def predicate_eval_message_edit(ctx: Context, old_msg: Message, new_msg: Message) -> bool:
+ """Return True if the edited message is the context message and the content was indeed modified."""
+ return new_msg.id == ctx.message.id and old_msg.content != new_msg.content
- try:
- async with ctx.typing():
- results = await self.post_eval(code)
- msg, error = self.get_results_message(results)
-
- if error:
- output, paste_link = error, None
- else:
- output, paste_link = await self.format_output(results["stdout"])
-
- icon = self.get_status_emoji(results)
- msg = f"{ctx.author.mention} {icon} {msg}.\n\n```py\n{output}\n```"
- if paste_link:
- msg = f"{msg}\nFull output: {paste_link}"
-
- response = await ctx.send(msg)
- self.bot.loop.create_task(
- wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot)
- )
- log.info(f"{ctx.author}'s job had a return code of {results['returncode']}")
- finally:
- del self.jobs[ctx.author.id]
+def predicate_eval_emoji_reaction(ctx: Context, reaction: Reaction, user: User) -> bool:
+ """Return True if the reaction REEVAL_EMOJI was added by the context message author on this message."""
+ return reaction.message.id == ctx.message.id and user.id == ctx.author.id and str(reaction) == REEVAL_EMOJI
def setup(bot: Bot) -> None:
diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py
index 6715ad6fb..d6891168f 100644
--- a/bot/cogs/sync/syncers.py
+++ b/bot/cogs/sync/syncers.py
@@ -23,7 +23,7 @@ _Diff = namedtuple('Diff', ('created', 'updated', 'deleted'))
class Syncer(abc.ABC):
"""Base class for synchronising the database with objects in the Discord cache."""
- _CORE_DEV_MENTION = f"<@&{constants.Roles.core_developer}> "
+ _CORE_DEV_MENTION = f"<@&{constants.Roles.core_developers}> "
_REACTION_EMOJIS = (constants.Emojis.check_mark, constants.Emojis.cross_mark)
def __init__(self, bot: Bot) -> None:
@@ -54,12 +54,12 @@ class Syncer(abc.ABC):
# Send to core developers if it's an automatic sync.
if not message:
log.trace("Message not provided for confirmation; creating a new one in dev-core.")
- channel = self.bot.get_channel(constants.Channels.devcore)
+ channel = self.bot.get_channel(constants.Channels.dev_core)
if not channel:
log.debug("Failed to get the dev-core channel from cache; attempting to fetch it.")
try:
- channel = await self.bot.fetch_channel(constants.Channels.devcore)
+ channel = await self.bot.fetch_channel(constants.Channels.dev_core)
except HTTPException:
log.exception(
f"Failed to fetch channel for sending sync confirmation prompt; "
@@ -93,7 +93,7 @@ class Syncer(abc.ABC):
`author` of the prompt.
"""
# For automatic syncs, check for the core dev role instead of an exact author
- has_role = any(constants.Roles.core_developer == role.id for role in user.roles)
+ has_role = any(constants.Roles.core_developers == role.id for role in user.roles)
return (
reaction.message.id == message.id
and not user.bot
diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py
index b6360dfae..5da9a4148 100644
--- a/bot/cogs/tags.py
+++ b/bot/cogs/tags.py
@@ -15,8 +15,7 @@ from bot.pagination import LinePaginator
log = logging.getLogger(__name__)
TEST_CHANNELS = (
- Channels.devtest,
- Channels.bot,
+ Channels.bot_commands,
Channels.helpers
)
@@ -221,7 +220,7 @@ class Tags(Cog):
))
@tags_group.command(name='delete', aliases=('remove', 'rm', 'd'))
- @with_role(Roles.admin, Roles.owner)
+ @with_role(Roles.admins, Roles.owners)
async def delete_command(self, ctx: Context, *, tag_name: TagNameConverter) -> None:
"""Remove a tag from the database."""
await self.bot.api_client.delete(f'bot/tags/{tag_name}')
diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py
index da278011a..94b9d6b5a 100644
--- a/bot/cogs/utils.py
+++ b/bot/cogs/utils.py
@@ -89,7 +89,7 @@ class Utils(Cog):
await ctx.message.channel.send(embed=pep_embed)
@command()
- @in_channel(Channels.bot, bypass_roles=STAFF_ROLES)
+ @in_channel(Channels.bot_commands, bypass_roles=STAFF_ROLES)
async def charinfo(self, ctx: Context, *, characters: str) -> None:
"""Shows you information on up to 25 unicode characters."""
match = re.match(r"<(a?):(\w+):(\d+)>", characters)
diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py
index e3c396863..57b50c34f 100644
--- a/bot/cogs/verification.py
+++ b/bot/cogs/verification.py
@@ -30,15 +30,16 @@ your information removed here as well.
Feel free to review them at any point!
Additionally, if you'd like to receive notifications for the announcements we post in <#{Channels.announcements}> \
-from time to time, you can send `!subscribe` to <#{Channels.bot}> at any time to assign yourself the \
+from time to time, you can send `!subscribe` to <#{Channels.bot_commands}> at any time to assign yourself the \
**Announcements** role. We'll mention this role every time we make an announcement.
-If you'd like to unsubscribe from the announcement notifications, simply send `!unsubscribe` to <#{Channels.bot}>.
+If you'd like to unsubscribe from the announcement notifications, simply send `!unsubscribe` to \
+<#{Channels.bot_commands}>.
"""
PERIODIC_PING = (
f"@everyone To verify that you have read our rules, please type `{BotConfig.prefix}accept`."
- f" If you encounter any problems during the verification process, ping the <@&{Roles.admin}> role in this channel."
+ f" If you encounter any problems during the verification process, ping the <@&{Roles.admins}> role in this channel."
)
BOT_MESSAGE_DELETE_DELAY = 10
@@ -136,7 +137,7 @@ class Verification(Cog):
await ctx.message.delete()
@command(name='subscribe')
- @in_channel(Channels.bot)
+ @in_channel(Channels.bot_commands)
async def subscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args
"""Subscribe to announcement notifications by assigning yourself the role."""
has_role = False
@@ -160,7 +161,7 @@ class Verification(Cog):
)
@command(name='unsubscribe')
- @in_channel(Channels.bot)
+ @in_channel(Channels.bot_commands)
async def unsubscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args
"""Unsubscribe from announcement notifications by removing the role from yourself."""
has_role = False
diff --git a/bot/constants.py b/bot/constants.py
index 9bc331dc4..14f8dc094 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -186,6 +186,11 @@ class YAMLGetter(type):
def __getitem__(cls, name):
return cls.__getattr__(name)
+ def __iter__(cls):
+ """Return generator of key: value pairs of current constants class' config values."""
+ for name in cls.__annotations__:
+ yield name, getattr(cls, name)
+
# Dataclasses
class Bot(metaclass=YAMLGetter):
@@ -358,18 +363,16 @@ class Channels(metaclass=YAMLGetter):
section = "guild"
subsection = "channels"
- admins: int
admin_spam: int
+ admins: int
announcements: int
attachment_log: int
big_brother_logs: int
- bot: int
- checkpoint_test: int
+ bot_commands: int
defcon: int
- devcontrib: int
- devcore: int
- devlog: int
- devtest: int
+ dev_contrib: int
+ dev_core: int
+ dev_log: int
esoteric: int
help_0: int
help_1: int
@@ -382,19 +385,19 @@ class Channels(metaclass=YAMLGetter):
helpers: int
message_log: int
meta: int
+ mod_alerts: int
+ mod_log: int
mod_spam: int
mods: int
- mod_alerts: int
- modlog: int
off_topic_0: int
off_topic_1: int
off_topic_2: int
organisation: int
- python: int
+ python_discussion: int
reddit: int
talent_pool: int
- userlog: int
- user_event_a: int
+ user_event_announcements: int
+ user_log: int
verification: int
voice_log: int
@@ -414,19 +417,18 @@ class Roles(metaclass=YAMLGetter):
section = "guild"
subsection = "roles"
- admin: int
+ admins: int
announcements: int
- champion: int
- contributor: int
- core_developer: int
+ contributors: int
+ core_developers: int
helpers: int
- jammer: int
- moderator: int
+ jammers: int
+ moderators: int
muted: int
- owner: int
+ owners: int
partners: int
- rockstars: int
- team_leader: int
+ python_community: int
+ team_leaders: int
verified: int # This is the Developers role on PyDis, here named verified for readability reasons.
@@ -434,9 +436,12 @@ class Guild(metaclass=YAMLGetter):
section = "guild"
id: int
- ignored: List[int]
- staff_channels: List[int]
+ moderation_channels: List[int]
+ moderation_roles: List[int]
+ modlog_blacklist: List[int]
reminder_whitelist: List[int]
+ staff_channels: List[int]
+ staff_roles: List[int]
class Keys(metaclass=YAMLGetter):
section = "keys"
@@ -582,14 +587,14 @@ BOT_DIR = os.path.dirname(__file__)
PROJECT_ROOT = os.path.abspath(os.path.join(BOT_DIR, os.pardir))
# Default role combinations
-MODERATION_ROLES = Roles.moderator, Roles.admin, Roles.owner
-STAFF_ROLES = Roles.helpers, Roles.moderator, Roles.admin, Roles.owner
+MODERATION_ROLES = Guild.moderation_roles
+STAFF_ROLES = Guild.staff_roles
# Roles combinations
STAFF_CHANNELS = Guild.staff_channels
# Default Channel combinations
-MODERATION_CHANNELS = Channels.admins, Channels.admin_spam, Channels.mod_alerts, Channels.mods, Channels.mod_spam
+MODERATION_CHANNELS = Guild.moderation_channels
# Bot replies
diff --git a/bot/converters.py b/bot/converters.py
index cca57a02d..1945e1da3 100644
--- a/bot/converters.py
+++ b/bot/converters.py
@@ -141,40 +141,24 @@ class TagNameConverter(Converter):
@staticmethod
async def convert(ctx: Context, tag_name: str) -> str:
"""Lowercase & strip whitespace from proposed tag_name & ensure it's valid."""
- def is_number(value: str) -> bool:
- """Check to see if the input string is numeric."""
- try:
- float(value)
- except ValueError:
- return False
- return True
-
tag_name = tag_name.lower().strip()
# The tag name has at least one invalid character.
if ascii(tag_name)[1:-1] != tag_name:
- log.warning(f"{ctx.author} tried to put an invalid character in a tag name. "
- "Rejecting the request.")
raise BadArgument("Don't be ridiculous, you can't use that character!")
# The tag name is either empty, or consists of nothing but whitespace.
elif not tag_name:
- log.warning(f"{ctx.author} tried to create a tag with a name consisting only of whitespace. "
- "Rejecting the request.")
raise BadArgument("Tag names should not be empty, or filled with whitespace.")
- # The tag name is a number of some kind, we don't allow that.
- elif is_number(tag_name):
- log.warning(f"{ctx.author} tried to create a tag with a digit as its name. "
- "Rejecting the request.")
- raise BadArgument("Tag names can't be numbers.")
-
# The tag name is longer than 127 characters.
elif len(tag_name) > 127:
- log.warning(f"{ctx.author} tried to request a tag name with over 127 characters. "
- "Rejecting the request.")
raise BadArgument("Are you insane? That's way too long!")
+ # The tag name is ascii but does not contain any letters.
+ elif not any(character.isalpha() for character in tag_name):
+ raise BadArgument("Tag names must contain at least one letter.")
+
return tag_name
@@ -192,8 +176,6 @@ class TagContentConverter(Converter):
# The tag contents should not be empty, or filled with whitespace.
if not tag_content:
- log.warning(f"{ctx.author} tried to create a tag containing only whitespace. "
- "Rejecting the request.")
raise BadArgument("Tag contents should not be empty, or filled with whitespace.")
return tag_content
diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py
index 8184be824..3e4b15ce4 100644
--- a/bot/utils/__init__.py
+++ b/bot/utils/__init__.py
@@ -1,5 +1,5 @@
from abc import ABCMeta
-from typing import Any, Generator, Hashable, Iterable
+from typing import Any, Hashable
from discord.ext.commands import CogMeta
@@ -64,13 +64,3 @@ class CaseInsensitiveDict(dict):
for k in list(self.keys()):
v = super(CaseInsensitiveDict, self).pop(k)
self.__setitem__(k, v)
-
-
-def chunks(iterable: Iterable, size: int) -> Generator[Any, None, None]:
- """
- Generator that allows you to iterate over any indexable collection in `size`-length chunks.
-
- Found: https://stackoverflow.com/a/312464/4022104
- """
- for i in range(0, len(iterable), size):
- yield iterable[i:i + size]
diff --git a/config-default.yml b/config-default.yml
index f70fe3c34..ab237423f 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -111,78 +111,135 @@ guild:
id: 267624335836053506
categories:
- python_help: 356013061213126657
+ python_help: 356013061213126657
channels:
- admins: &ADMINS 365960823622991872
- admin_spam: &ADMIN_SPAM 563594791770914816
- admins_voice: &ADMINS_VOICE 500734494840717332
- announcements: 354619224620138496
- attachment_log: &ATTCH_LOG 649243850006855680
- big_brother_logs: &BBLOGS 468507907357409333
- bot: &BOT_CMD 267659945086812160
- checkpoint_test: 422077681434099723
- defcon: &DEFCON 464469101889454091
- devcontrib: &DEV_CONTRIB 635950537262759947
- devcore: 411200599653351425
- devlog: &DEVLOG 622895325144940554
- devtest: &DEVTEST 414574275865870337
- esoteric: 470884583684964352
- help_0: 303906576991780866
- help_1: 303906556754395136
- help_2: 303906514266226689
- help_3: 439702951246692352
- help_4: 451312046647148554
- help_5: 454941769734422538
- help_6: 587375753306570782
- help_7: 587375768556797982
- helpers: &HELPERS 385474242440986624
- message_log: &MESSAGE_LOG 467752170159079424
- meta: 429409067623251969
- mod_spam: &MOD_SPAM 620607373828030464
- mods: &MODS 305126844661760000
- mod_alerts: 473092532147060736
- modlog: &MODLOG 282638479504965634
- off_topic_0: 291284109232308226
- off_topic_1: 463035241142026251
- off_topic_2: 463035268514185226
- organisation: &ORGANISATION 551789653284356126
- python: 267624335836053506
- reddit: 458224812528238616
- staff_lounge: &STAFF_LOUNGE 464905259261755392
- staff_voice: &STAFF_VOICE 412375055910043655
- talent_pool: &TALENT_POOL 534321732593647616
- userlog: 528976905546760203
- user_event_a: &USER_EVENT_A 592000283102674944
- verification: 352442727016693763
- voice_log: 640292421988646961
-
- staff_channels: [*ADMINS, *ADMIN_SPAM, *MOD_SPAM, *MODS, *HELPERS, *ORGANISATION, *DEFCON]
- ignored: [*ADMINS, *MESSAGE_LOG, *MODLOG, *ADMINS_VOICE, *STAFF_VOICE, *ATTCH_LOG]
- reminder_whitelist: [*BOT_CMD, *DEV_CONTRIB]
+ announcements: 354619224620138496
+ user_event_announcements: &USER_EVENT_A 592000283102674944
+
+ # Development
+ dev_contrib: &DEV_CONTRIB 635950537262759947
+ dev_core: &DEV_CORE 411200599653351425
+ dev_log: &DEV_LOG 622895325144940554
+
+ # Discussion
+ meta: 429409067623251969
+ python_discussion: 267624335836053506
+
+ # Logs
+ attachment_log: &ATTACH_LOG 649243850006855680
+ message_log: &MESSAGE_LOG 467752170159079424
+ mod_log: &MOD_LOG 282638479504965634
+ user_log: 528976905546760203
+ voice_log: 640292421988646961
+
+ # Off-topic
+ off_topic_0: 291284109232308226
+ off_topic_1: 463035241142026251
+ off_topic_2: 463035268514185226
+
+ # Python Help
+ help_0: 303906576991780866
+ help_1: 303906556754395136
+ help_2: 303906514266226689
+ help_3: 439702951246692352
+ help_4: 451312046647148554
+ help_5: 454941769734422538
+ help_6: 587375753306570782
+ help_7: 587375768556797982
+
+ # Special
+ bot_commands: &BOT_CMD 267659945086812160
+ esoteric: 470884583684964352
+ reddit: 458224812528238616
+ verification: 352442727016693763
+
+ # Staff
+ admins: &ADMINS 365960823622991872
+ admin_spam: &ADMIN_SPAM 563594791770914816
+ defcon: &DEFCON 464469101889454091
+ helpers: &HELPERS 385474242440986624
+ mods: &MODS 305126844661760000
+ mod_alerts: &MOD_ALERTS 473092532147060736
+ mod_spam: &MOD_SPAM 620607373828030464
+ organisation: &ORGANISATION 551789653284356126
+ staff_lounge: &STAFF_LOUNGE 464905259261755392
+
+ # Voice
+ admins_voice: &ADMINS_VOICE 500734494840717332
+ staff_voice: &STAFF_VOICE 412375055910043655
+
+ # Watch
+ big_brother_logs: &BB_LOGS 468507907357409333
+ talent_pool: &TALENT_POOL 534321732593647616
+
+ staff_channels:
+ - *ADMINS
+ - *ADMIN_SPAM
+ - *DEFCON
+ - *HELPERS
+ - *MODS
+ - *MOD_SPAM
+ - *ORGANISATION
+
+ moderation_channels:
+ - *ADMINS
+ - *ADMIN_SPAM
+ - *MOD_ALERTS
+ - *MODS
+ - *MOD_SPAM
+
+ # Modlog cog ignores events which occur in these channels
+ modlog_blacklist:
+ - *ADMINS
+ - *ADMINS_VOICE
+ - *ATTACH_LOG
+ - *MESSAGE_LOG
+ - *MOD_LOG
+ - *STAFF_VOICE
+
+ reminder_whitelist:
+ - *BOT_CMD
+ - *DEV_CONTRIB
roles:
- admin: &ADMIN_ROLE 267628507062992896
- announcements: 463658397560995840
- champion: 430492892331769857
- contributor: 295488872404484098
- core_developer: 587606783669829632
- helpers: 267630620367257601
- jammer: 591786436651646989
- moderator: &MOD_ROLE 267629731250176001
- muted: &MUTED_ROLE 277914926603829249
- owner: &OWNER_ROLE 267627879762755584
- partners: 323426753857191936
- rockstars: &ROCKSTARS_ROLE 458226413825294336
- team_leader: 501324292341104650
- verified: 352427296948486144
+ announcements: 463658397560995840
+ contributors: 295488872404484098
+ muted: &MUTED_ROLE 277914926603829249
+ partners: 323426753857191936
+ python_community: &PY_COMMUNITY_ROLE 458226413825294336
+
+ # This is the Developers role on PyDis, here named verified for readability reasons
+ verified: 352427296948486144
+
+ # Staff
+ admins: &ADMINS_ROLE 267628507062992896
+ core_developers: 587606783669829632
+ helpers: &HELPERS_ROLE 267630620367257601
+ moderators: &MODS_ROLE 267629731250176001
+ owners: &OWNERS_ROLE 267627879762755584
+
+ # Code Jam
+ jammers: 591786436651646989
+ team_leaders: 501324292341104650
+
+ moderation_roles:
+ - *OWNERS_ROLE
+ - *ADMINS_ROLE
+ - *MODS_ROLE
+
+ staff_roles:
+ - *OWNERS_ROLE
+ - *ADMINS_ROLE
+ - *MODS_ROLE
+ - *HELPERS_ROLE
webhooks:
- talent_pool: 569145364800602132
- big_brother: 569133704568373283
- reddit: 635408384794951680
- duck_pond: 637821475327311927
- dev_log: 680501655111729222
+ talent_pool: 569145364800602132
+ big_brother: 569133704568373283
+ reddit: 635408384794951680
+ duck_pond: 637821475327311927
+ dev_log: 680501655111729222
filter:
@@ -260,20 +317,19 @@ filter:
# Censor doesn't apply to these
channel_whitelist:
- *ADMINS
- - *MODLOG
+ - *MOD_LOG
- *MESSAGE_LOG
- - *DEVLOG
- - *BBLOGS
+ - *DEV_LOG
+ - *BB_LOGS
- *STAFF_LOUNGE
- - *DEVTEST
- *TALENT_POOL
- *USER_EVENT_A
role_whitelist:
- - *ADMIN_ROLE
- - *MOD_ROLE
- - *OWNER_ROLE
- - *ROCKSTARS_ROLE
+ - *ADMINS_ROLE
+ - *MODS_ROLE
+ - *OWNERS_ROLE
+ - *PY_COMMUNITY_ROLE
keys:
@@ -441,7 +497,20 @@ sync:
duck_pond:
threshold: 5
- custom_emojis: [*DUCKY_YELLOW, *DUCKY_BLURPLE, *DUCKY_CAMO, *DUCKY_DEVIL, *DUCKY_NINJA, *DUCKY_REGAL, *DUCKY_TUBE, *DUCKY_HUNT, *DUCKY_WIZARD, *DUCKY_PARTY, *DUCKY_ANGEL, *DUCKY_MAUL, *DUCKY_SANTA]
+ custom_emojis:
+ - *DUCKY_YELLOW
+ - *DUCKY_BLURPLE
+ - *DUCKY_CAMO
+ - *DUCKY_DEVIL
+ - *DUCKY_NINJA
+ - *DUCKY_REGAL
+ - *DUCKY_TUBE
+ - *DUCKY_HUNT
+ - *DUCKY_WIZARD
+ - *DUCKY_PARTY
+ - *DUCKY_ANGEL
+ - *DUCKY_MAUL
+ - *DUCKY_SANTA
config:
required_keys: ['bot.token']
diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py
index e6a6f9688..c2e143865 100644
--- a/tests/bot/cogs/sync/test_base.py
+++ b/tests/bot/cogs/sync/test_base.py
@@ -84,7 +84,7 @@ class SyncerSendPromptTests(unittest.TestCase):
mock_()
await self.syncer._send_prompt()
- method.assert_called_once_with(constants.Channels.devcore)
+ method.assert_called_once_with(constants.Channels.dev_core)
@helpers.async_test
async def test_send_prompt_returns_None_if_channel_fetch_fails(self):
@@ -135,7 +135,7 @@ class SyncerConfirmationTests(unittest.TestCase):
def setUp(self):
self.bot = helpers.MockBot()
self.syncer = TestSyncer(self.bot)
- self.core_dev_role = helpers.MockRole(id=constants.Roles.core_developer)
+ self.core_dev_role = helpers.MockRole(id=constants.Roles.core_developers)
@staticmethod
def get_message_reaction(emoji):
diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py
index deae7ebad..8443cfe71 100644
--- a/tests/bot/cogs/test_information.py
+++ b/tests/bot/cogs/test_information.py
@@ -19,7 +19,7 @@ class InformationCogTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
- cls.moderator_role = helpers.MockRole(name="Moderator", id=constants.Roles.moderator)
+ cls.moderator_role = helpers.MockRole(name="Moderator", id=constants.Roles.moderators)
def setUp(self):
"""Sets up fresh objects for each test."""
@@ -521,7 +521,7 @@ class UserCommandTests(unittest.TestCase):
"""A regular user should not be able to use this command outside of bot-commands."""
constants.MODERATION_ROLES = [self.moderator_role.id]
constants.STAFF_ROLES = [self.moderator_role.id]
- constants.Channels.bot = 50
+ constants.Channels.bot_commands = 50
ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=100))
@@ -533,7 +533,7 @@ class UserCommandTests(unittest.TestCase):
def test_regular_user_may_use_command_in_bot_commands_channel(self, create_embed, constants):
"""A regular user should be allowed to use `!user` targeting themselves in bot-commands."""
constants.STAFF_ROLES = [self.moderator_role.id]
- constants.Channels.bot = 50
+ constants.Channels.bot_commands = 50
ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50))
@@ -546,7 +546,7 @@ class UserCommandTests(unittest.TestCase):
def test_regular_user_can_explicitly_target_themselves(self, create_embed, constants):
"""A user should target itself with `!user` when a `user` argument was not provided."""
constants.STAFF_ROLES = [self.moderator_role.id]
- constants.Channels.bot = 50
+ constants.Channels.bot_commands = 50
ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50))
@@ -559,7 +559,7 @@ class UserCommandTests(unittest.TestCase):
def test_staff_members_can_bypass_channel_restriction(self, create_embed, constants):
"""Staff members should be able to bypass the bot-commands channel restriction."""
constants.STAFF_ROLES = [self.moderator_role.id]
- constants.Channels.bot = 50
+ constants.Channels.bot_commands = 50
ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=200))
diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py
new file mode 100644
index 000000000..985bc66a1
--- /dev/null
+++ b/tests/bot/cogs/test_snekbox.py
@@ -0,0 +1,368 @@
+import asyncio
+import logging
+import unittest
+from functools import partial
+from unittest.mock import MagicMock, Mock, call, patch
+
+from bot.cogs import snekbox
+from bot.cogs.snekbox import Snekbox
+from bot.constants import URLs
+from tests.helpers import (
+ AsyncContextManagerMock, AsyncMock, MockBot, MockContext, MockMessage, MockReaction, MockUser, async_test
+)
+
+
+class SnekboxTests(unittest.TestCase):
+ def setUp(self):
+ """Add mocked bot and cog to the instance."""
+ self.bot = MockBot()
+
+ self.mocked_post = MagicMock()
+ self.mocked_post.json = AsyncMock()
+ self.bot.http_session.post = MagicMock(return_value=AsyncContextManagerMock(self.mocked_post))
+
+ self.cog = Snekbox(bot=self.bot)
+
+ @async_test
+ async def test_post_eval(self):
+ """Post the eval code to the URLs.snekbox_eval_api endpoint."""
+ self.mocked_post.json.return_value = {'lemon': 'AI'}
+
+ self.assertEqual(await self.cog.post_eval("import random"), {'lemon': 'AI'})
+ self.bot.http_session.post.assert_called_once_with(
+ URLs.snekbox_eval_api,
+ json={"input": "import random"},
+ raise_for_status=True
+ )
+
+ @async_test
+ async def test_upload_output_reject_too_long(self):
+ """Reject output longer than MAX_PASTE_LEN."""
+ result = await self.cog.upload_output("-" * (snekbox.MAX_PASTE_LEN + 1))
+ self.assertEqual(result, "too long to upload")
+
+ @async_test
+ async def test_upload_output(self):
+ """Upload the eval output to the URLs.paste_service.format(key="documents") endpoint."""
+ key = "RainbowDash"
+ self.mocked_post.json.return_value = {"key": key}
+
+ self.assertEqual(
+ await self.cog.upload_output("My awesome output"),
+ URLs.paste_service.format(key=key)
+ )
+ self.bot.http_session.post.assert_called_once_with(
+ URLs.paste_service.format(key="documents"),
+ data="My awesome output",
+ raise_for_status=True
+ )
+
+ @async_test
+ async def test_upload_output_gracefully_fallback_if_exception_during_request(self):
+ """Output upload gracefully fallback if the upload fail."""
+ self.mocked_post.json.side_effect = Exception
+ log = logging.getLogger("bot.cogs.snekbox")
+ with self.assertLogs(logger=log, level='ERROR'):
+ await self.cog.upload_output('My awesome output!')
+
+ @async_test
+ async def test_upload_output_gracefully_fallback_if_no_key_in_response(self):
+ """Output upload gracefully fallback if there is no key entry in the response body."""
+ self.mocked_post.json.return_value = {}
+ self.assertEqual((await self.cog.upload_output('My awesome output!')), None)
+
+ def test_prepare_input(self):
+ cases = (
+ ('print("Hello world!")', 'print("Hello world!")', 'non-formatted'),
+ ('`print("Hello world!")`', 'print("Hello world!")', 'one line code block'),
+ ('```\nprint("Hello world!")```', 'print("Hello world!")', 'multiline code block'),
+ ('```py\nprint("Hello world!")```', 'print("Hello world!")', 'multiline python code block'),
+ )
+ for case, expected, testname in cases:
+ with self.subTest(msg=f'Extract code from {testname}.'):
+ self.assertEqual(self.cog.prepare_input(case), expected)
+
+ def test_get_results_message(self):
+ """Return error and message according to the eval result."""
+ cases = (
+ ('ERROR', None, ('Your eval job has failed', 'ERROR')),
+ ('', 128 + snekbox.SIGKILL, ('Your eval job timed out or ran out of memory', '')),
+ ('', 255, ('Your eval job has failed', 'A fatal NsJail error occurred'))
+ )
+ for stdout, returncode, expected in cases:
+ with self.subTest(stdout=stdout, returncode=returncode, expected=expected):
+ actual = self.cog.get_results_message({'stdout': stdout, 'returncode': returncode})
+ self.assertEqual(actual, expected)
+
+ @patch('bot.cogs.snekbox.Signals', side_effect=ValueError)
+ def test_get_results_message_invalid_signal(self, mock_Signals: Mock):
+ self.assertEqual(
+ self.cog.get_results_message({'stdout': '', 'returncode': 127}),
+ ('Your eval job has completed with return code 127', '')
+ )
+
+ @patch('bot.cogs.snekbox.Signals')
+ def test_get_results_message_valid_signal(self, mock_Signals: Mock):
+ mock_Signals.return_value.name = 'SIGTEST'
+ self.assertEqual(
+ self.cog.get_results_message({'stdout': '', 'returncode': 127}),
+ ('Your eval job has completed with return code 127 (SIGTEST)', '')
+ )
+
+ def test_get_status_emoji(self):
+ """Return emoji according to the eval result."""
+ cases = (
+ (' ', -1, ':warning:'),
+ ('Hello world!', 0, ':white_check_mark:'),
+ ('Invalid beard size', -1, ':x:')
+ )
+ for stdout, returncode, expected in cases:
+ with self.subTest(stdout=stdout, returncode=returncode, expected=expected):
+ actual = self.cog.get_status_emoji({'stdout': stdout, 'returncode': returncode})
+ self.assertEqual(actual, expected)
+
+ @async_test
+ async def test_format_output(self):
+ """Test output formatting."""
+ self.cog.upload_output = AsyncMock(return_value='https://testificate.com/')
+
+ too_many_lines = (
+ '001 | v\n002 | e\n003 | r\n004 | y\n005 | l\n006 | o\n'
+ '007 | n\n008 | g\n009 | b\n010 | e\n011 | a\n... (truncated - too many lines)'
+ )
+ too_long_too_many_lines = (
+ "\n".join(
+ f"{i:03d} | {line}" for i, line in enumerate(['verylongbeard' * 10] * 15, 1)
+ )[:1000] + "\n... (truncated - too long, too many lines)"
+ )
+
+ cases = (
+ ('', ('[No output]', None), 'No output'),
+ ('My awesome output', ('My awesome output', None), 'One line output'),
+ ('<@', ("<@\u200B", None), r'Convert <@ to <@\u200B'),
+ ('<!@', ("<!@\u200B", None), r'Convert <!@ to <!@\u200B'),
+ (
+ '\u202E\u202E\u202E',
+ ('Code block escape attempt detected; will not output result', None),
+ 'Detect RIGHT-TO-LEFT OVERRIDE'
+ ),
+ (
+ '\u200B\u200B\u200B',
+ ('Code block escape attempt detected; will not output result', None),
+ 'Detect ZERO WIDTH SPACE'
+ ),
+ ('long\nbeard', ('001 | long\n002 | beard', None), 'Two line output'),
+ (
+ 'v\ne\nr\ny\nl\no\nn\ng\nb\ne\na\nr\nd',
+ (too_many_lines, 'https://testificate.com/'),
+ '12 lines output'
+ ),
+ (
+ 'verylongbeard' * 100,
+ ('verylongbeard' * 76 + 'verylongbear\n... (truncated - too long)', 'https://testificate.com/'),
+ '1300 characters output'
+ ),
+ (
+ ('verylongbeard' * 10 + '\n') * 15,
+ (too_long_too_many_lines, 'https://testificate.com/'),
+ '15 lines, 1965 characters output'
+ ),
+ )
+ for case, expected, testname in cases:
+ with self.subTest(msg=testname, case=case, expected=expected):
+ self.assertEqual(await self.cog.format_output(case), expected)
+
+ @async_test
+ async def test_eval_command_evaluate_once(self):
+ """Test the eval command procedure."""
+ ctx = MockContext()
+ response = MockMessage()
+ self.cog.prepare_input = MagicMock(return_value='MyAwesomeFormattedCode')
+ self.cog.send_eval = AsyncMock(return_value=response)
+ self.cog.continue_eval = AsyncMock(return_value=None)
+
+ await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode')
+ self.cog.prepare_input.assert_called_once_with('MyAwesomeCode')
+ self.cog.send_eval.assert_called_once_with(ctx, 'MyAwesomeFormattedCode')
+ self.cog.continue_eval.assert_called_once_with(ctx, response)
+
+ @async_test
+ async def test_eval_command_evaluate_twice(self):
+ """Test the eval and re-eval command procedure."""
+ ctx = MockContext()
+ response = MockMessage()
+ self.cog.prepare_input = MagicMock(return_value='MyAwesomeFormattedCode')
+ self.cog.send_eval = AsyncMock(return_value=response)
+ self.cog.continue_eval = AsyncMock()
+ self.cog.continue_eval.side_effect = ('MyAwesomeCode-2', None)
+
+ await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode')
+ self.cog.prepare_input.has_calls(call('MyAwesomeCode'), call('MyAwesomeCode-2'))
+ self.cog.send_eval.assert_called_with(ctx, 'MyAwesomeFormattedCode')
+ self.cog.continue_eval.assert_called_with(ctx, response)
+
+ @async_test
+ async def test_eval_command_reject_two_eval_at_the_same_time(self):
+ """Test if the eval command rejects an eval if the author already have a running eval."""
+ ctx = MockContext()
+ ctx.author.id = 42
+ ctx.author.mention = '@LemonLemonishBeard#0042'
+ ctx.send = AsyncMock()
+ self.cog.jobs = (42,)
+ await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode')
+ ctx.send.assert_called_once_with(
+ "@LemonLemonishBeard#0042 You've already got a job running - please wait for it to finish!"
+ )
+
+ @async_test
+ async def test_eval_command_call_help(self):
+ """Test if the eval command call the help command if no code is provided."""
+ ctx = MockContext()
+ ctx.invoke = AsyncMock()
+ await self.cog.eval_command.callback(self.cog, ctx=ctx, code='')
+ ctx.invoke.assert_called_once_with(self.bot.get_command("help"), "eval")
+
+ @async_test
+ async def test_send_eval(self):
+ """Test the send_eval function."""
+ ctx = MockContext()
+ ctx.message = MockMessage()
+ ctx.send = AsyncMock()
+ ctx.author.mention = '@LemonLemonishBeard#0042'
+ ctx.typing = MagicMock(return_value=AsyncContextManagerMock(None))
+ self.cog.post_eval = AsyncMock(return_value={'stdout': '', 'returncode': 0})
+ self.cog.get_results_message = MagicMock(return_value=('Return code 0', ''))
+ self.cog.get_status_emoji = MagicMock(return_value=':yay!:')
+ self.cog.format_output = AsyncMock(return_value=('[No output]', None))
+
+ await self.cog.send_eval(ctx, 'MyAwesomeCode')
+ ctx.send.assert_called_once_with(
+ '@LemonLemonishBeard#0042 :yay!: Return code 0.\n\n```py\n[No output]\n```'
+ )
+ self.cog.post_eval.assert_called_once_with('MyAwesomeCode')
+ self.cog.get_status_emoji.assert_called_once_with({'stdout': '', 'returncode': 0})
+ self.cog.get_results_message.assert_called_once_with({'stdout': '', 'returncode': 0})
+ self.cog.format_output.assert_called_once_with('')
+
+ @async_test
+ async def test_send_eval_with_paste_link(self):
+ """Test the send_eval function with a too long output that generate a paste link."""
+ ctx = MockContext()
+ ctx.message = MockMessage()
+ ctx.send = AsyncMock()
+ ctx.author.mention = '@LemonLemonishBeard#0042'
+ ctx.typing = MagicMock(return_value=AsyncContextManagerMock(None))
+ self.cog.post_eval = AsyncMock(return_value={'stdout': 'Way too long beard', 'returncode': 0})
+ self.cog.get_results_message = MagicMock(return_value=('Return code 0', ''))
+ self.cog.get_status_emoji = MagicMock(return_value=':yay!:')
+ self.cog.format_output = AsyncMock(return_value=('Way too long beard', 'lookatmybeard.com'))
+
+ await self.cog.send_eval(ctx, 'MyAwesomeCode')
+ ctx.send.assert_called_once_with(
+ '@LemonLemonishBeard#0042 :yay!: Return code 0.'
+ '\n\n```py\nWay too long beard\n```\nFull output: lookatmybeard.com'
+ )
+ self.cog.post_eval.assert_called_once_with('MyAwesomeCode')
+ self.cog.get_status_emoji.assert_called_once_with({'stdout': 'Way too long beard', 'returncode': 0})
+ self.cog.get_results_message.assert_called_once_with({'stdout': 'Way too long beard', 'returncode': 0})
+ self.cog.format_output.assert_called_once_with('Way too long beard')
+
+ @async_test
+ async def test_send_eval_with_non_zero_eval(self):
+ """Test the send_eval function with a code returning a non-zero code."""
+ ctx = MockContext()
+ ctx.message = MockMessage()
+ ctx.send = AsyncMock()
+ ctx.author.mention = '@LemonLemonishBeard#0042'
+ ctx.typing = MagicMock(return_value=AsyncContextManagerMock(None))
+ self.cog.post_eval = AsyncMock(return_value={'stdout': 'ERROR', 'returncode': 127})
+ self.cog.get_results_message = MagicMock(return_value=('Return code 127', 'Beard got stuck in the eval'))
+ self.cog.get_status_emoji = MagicMock(return_value=':nope!:')
+ self.cog.format_output = AsyncMock() # This function isn't called
+
+ await self.cog.send_eval(ctx, 'MyAwesomeCode')
+ ctx.send.assert_called_once_with(
+ '@LemonLemonishBeard#0042 :nope!: Return code 127.\n\n```py\nBeard got stuck in the eval\n```'
+ )
+ self.cog.post_eval.assert_called_once_with('MyAwesomeCode')
+ self.cog.get_status_emoji.assert_called_once_with({'stdout': 'ERROR', 'returncode': 127})
+ self.cog.get_results_message.assert_called_once_with({'stdout': 'ERROR', 'returncode': 127})
+ self.cog.format_output.assert_not_called()
+
+ @async_test
+ async def test_continue_eval_does_continue(self):
+ """Test that the continue_eval function does continue if required conditions are met."""
+ ctx = MockContext(message=MockMessage(add_reaction=AsyncMock(), clear_reactions=AsyncMock()))
+ response = MockMessage(delete=AsyncMock())
+ new_msg = MockMessage(content='!e NewCode')
+ self.bot.wait_for.side_effect = ((None, new_msg), None)
+
+ actual = await self.cog.continue_eval(ctx, response)
+ self.assertEqual(actual, 'NewCode')
+ self.bot.wait_for.has_calls(
+ call('message_edit', partial(snekbox.predicate_eval_message_edit, ctx), timeout=10),
+ call('reaction_add', partial(snekbox.predicate_eval_emoji_reaction, ctx), timeout=10)
+ )
+ ctx.message.add_reaction.assert_called_once_with(snekbox.REEVAL_EMOJI)
+ ctx.message.clear_reactions.assert_called_once()
+ response.delete.assert_called_once()
+
+ @async_test
+ async def test_continue_eval_does_not_continue(self):
+ ctx = MockContext(message=MockMessage(clear_reactions=AsyncMock()))
+ self.bot.wait_for.side_effect = asyncio.TimeoutError
+
+ actual = await self.cog.continue_eval(ctx, MockMessage())
+ self.assertEqual(actual, None)
+ ctx.message.clear_reactions.assert_called_once()
+
+ def test_predicate_eval_message_edit(self):
+ """Test the predicate_eval_message_edit function."""
+ msg0 = MockMessage(id=1, content='abc')
+ msg1 = MockMessage(id=2, content='abcdef')
+ msg2 = MockMessage(id=1, content='abcdef')
+
+ cases = (
+ (msg0, msg0, False, 'same ID, same content'),
+ (msg0, msg1, False, 'different ID, different content'),
+ (msg0, msg2, True, 'same ID, different content')
+ )
+ for ctx_msg, new_msg, expected, testname in cases:
+ with self.subTest(msg=f'Messages with {testname} return {expected}'):
+ ctx = MockContext(message=ctx_msg)
+ actual = snekbox.predicate_eval_message_edit(ctx, ctx_msg, new_msg)
+ self.assertEqual(actual, expected)
+
+ def test_predicate_eval_emoji_reaction(self):
+ """Test the predicate_eval_emoji_reaction function."""
+ valid_reaction = MockReaction(message=MockMessage(id=1))
+ valid_reaction.__str__.return_value = snekbox.REEVAL_EMOJI
+ valid_ctx = MockContext(message=MockMessage(id=1), author=MockUser(id=2))
+ valid_user = MockUser(id=2)
+
+ invalid_reaction_id = MockReaction(message=MockMessage(id=42))
+ invalid_reaction_id.__str__.return_value = snekbox.REEVAL_EMOJI
+ invalid_user_id = MockUser(id=42)
+ invalid_reaction_str = MockReaction(message=MockMessage(id=1))
+ invalid_reaction_str.__str__.return_value = ':longbeard:'
+
+ cases = (
+ (invalid_reaction_id, valid_user, False, 'invalid reaction ID'),
+ (valid_reaction, invalid_user_id, False, 'invalid user ID'),
+ (invalid_reaction_str, valid_user, False, 'invalid reaction __str__'),
+ (valid_reaction, valid_user, True, 'matching attributes')
+ )
+ for reaction, user, expected, testname in cases:
+ with self.subTest(msg=f'Test with {testname} and expected return {expected}'):
+ actual = snekbox.predicate_eval_emoji_reaction(valid_ctx, reaction, user)
+ self.assertEqual(actual, expected)
+
+
+class SnekboxSetupTests(unittest.TestCase):
+ """Tests setup of the `Snekbox` cog."""
+
+ def test_setup(self):
+ """Setup of the extension should call add_cog."""
+ bot = MockBot()
+ snekbox.setup(bot)
+ bot.add_cog.assert_called_once()
diff --git a/tests/bot/test_converters.py b/tests/bot/test_converters.py
index b2b78d9dd..1e5ca62ae 100644
--- a/tests/bot/test_converters.py
+++ b/tests/bot/test_converters.py
@@ -68,7 +68,7 @@ class ConverterTests(unittest.TestCase):
('👋', "Don't be ridiculous, you can't use that character!"),
('', "Tag names should not be empty, or filled with whitespace."),
(' ', "Tag names should not be empty, or filled with whitespace."),
- ('42', "Tag names can't be numbers."),
+ ('42', "Tag names must contain at least one letter."),
('x' * 128, "Are you insane? That's way too long!"),
)
diff --git a/tests/bot/test_utils.py b/tests/bot/test_utils.py
index 58ae2a81a..d7bcc3ba6 100644
--- a/tests/bot/test_utils.py
+++ b/tests/bot/test_utils.py
@@ -35,18 +35,3 @@ class CaseInsensitiveDictTests(unittest.TestCase):
instance = utils.CaseInsensitiveDict()
instance.update({'FOO': 'bar'})
self.assertEqual(instance['foo'], 'bar')
-
-
-class ChunkTests(unittest.TestCase):
- """Tests the `chunk` method."""
-
- def test_empty_chunking(self):
- """Tests chunking on an empty iterable."""
- generator = utils.chunks(iterable=[], size=5)
- self.assertEqual(list(generator), [])
-
- def test_list_chunking(self):
- """Tests chunking a non-empty list."""
- iterable = [1, 2, 3, 4, 5]
- generator = utils.chunks(iterable=iterable, size=2)
- self.assertEqual(list(generator), [[1, 2], [3, 4], [5]])
diff --git a/tests/helpers.py b/tests/helpers.py
index 9d9dd5da6..6f50f6ae3 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -128,6 +128,18 @@ class AsyncMock(CustomMockMixin, unittest.mock.MagicMock):
return super().__call__(*args, **kwargs)
+class AsyncContextManagerMock(unittest.mock.MagicMock):
+ def __init__(self, return_value: Any):
+ super().__init__()
+ self._return_value = return_value
+
+ async def __aenter__(self):
+ return self._return_value
+
+ async def __aexit__(self, *args):
+ pass
+
+
class AsyncIteratorMock:
"""
A class to mock asynchronous iterators.