aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--bot/__main__.py52
-rw-r--r--bot/cogs/moderation/__init__.py19
-rw-r--r--bot/cogs/sync/__init__.py7
-rw-r--r--bot/cogs/watchchannels/__init__.py9
-rw-r--r--bot/constants.py2
-rw-r--r--bot/decorators.py29
-rw-r--r--bot/exts/__init__.py (renamed from bot/cogs/__init__.py)0
-rw-r--r--bot/exts/backend/__init__.py (renamed from tests/bot/cogs/__init__.py)0
-rw-r--r--bot/exts/backend/alias.py (renamed from bot/cogs/alias.py)0
-rw-r--r--bot/exts/backend/config_verifier.py (renamed from bot/cogs/config_verifier.py)0
-rw-r--r--bot/exts/backend/error_handler.py (renamed from bot/cogs/error_handler.py)0
-rw-r--r--bot/exts/backend/logging.py (renamed from bot/cogs/logging.py)0
-rw-r--r--bot/exts/backend/sync/__init__.py8
-rw-r--r--bot/exts/backend/sync/_cog.py (renamed from bot/cogs/sync/cog.py)6
-rw-r--r--bot/exts/backend/sync/_syncers.py (renamed from bot/cogs/sync/syncers.py)0
-rw-r--r--bot/exts/filters/__init__.py (renamed from tests/bot/cogs/moderation/__init__.py)0
-rw-r--r--bot/exts/filters/antimalware.py (renamed from bot/cogs/antimalware.py)0
-rw-r--r--bot/exts/filters/antispam.py (renamed from bot/cogs/antispam.py)5
-rw-r--r--bot/exts/filters/filter_lists.py (renamed from bot/cogs/filter_lists.py)7
-rw-r--r--bot/exts/filters/filtering.py (renamed from bot/cogs/filtering.py)40
-rw-r--r--bot/exts/filters/security.py (renamed from bot/cogs/security.py)0
-rw-r--r--bot/exts/filters/token_remover.py (renamed from bot/cogs/token_remover.py)2
-rw-r--r--bot/exts/filters/webhook_remover.py (renamed from bot/cogs/webhook_remover.py)2
-rw-r--r--bot/exts/fun/__init__.py (renamed from tests/bot/cogs/sync/__init__.py)0
-rw-r--r--bot/exts/fun/duck_pond.py (renamed from bot/cogs/duck_pond.py)0
-rw-r--r--bot/exts/fun/off_topic_names.py (renamed from bot/cogs/off_topic_names.py)15
-rw-r--r--bot/exts/help_channels.py (renamed from bot/cogs/help_channels.py)7
-rw-r--r--bot/exts/info/__init__.py0
-rw-r--r--bot/exts/info/doc.py (renamed from bot/cogs/doc.py)7
-rw-r--r--bot/exts/info/help.py (renamed from bot/cogs/help.py)0
-rw-r--r--bot/exts/info/information.py (renamed from bot/cogs/information.py)15
-rw-r--r--bot/exts/info/python_news.py (renamed from bot/cogs/python_news.py)0
-rw-r--r--bot/exts/info/reddit.py (renamed from bot/cogs/reddit.py)5
-rw-r--r--bot/exts/info/site.py (renamed from bot/cogs/site.py)0
-rw-r--r--bot/exts/info/source.py (renamed from bot/cogs/source.py)0
-rw-r--r--bot/exts/info/stats.py (renamed from bot/cogs/stats.py)0
-rw-r--r--bot/exts/info/tags.py (renamed from bot/cogs/tags.py)0
-rw-r--r--bot/exts/moderation/__init__.py0
-rw-r--r--bot/exts/moderation/defcon.py (renamed from bot/cogs/defcon.py)15
-rw-r--r--bot/exts/moderation/dm_relay.py (renamed from bot/cogs/dm_relay.py)6
-rw-r--r--bot/exts/moderation/incidents.py (renamed from bot/cogs/moderation/incidents.py)5
-rw-r--r--bot/exts/moderation/infraction/__init__.py0
-rw-r--r--bot/exts/moderation/infraction/_scheduler.py (renamed from bot/cogs/moderation/scheduler.py)24
-rw-r--r--bot/exts/moderation/infraction/_utils.py (renamed from bot/cogs/moderation/utils.py)37
-rw-r--r--bot/exts/moderation/infraction/infractions.py (renamed from bot/cogs/moderation/infractions.py)36
-rw-r--r--bot/exts/moderation/infraction/management.py (renamed from bot/cogs/moderation/management.py)21
-rw-r--r--bot/exts/moderation/infraction/superstarify.py (renamed from bot/cogs/moderation/superstarify.py)36
-rw-r--r--bot/exts/moderation/modlog.py (renamed from bot/cogs/moderation/modlog.py)5
-rw-r--r--bot/exts/moderation/silence.py (renamed from bot/cogs/moderation/silence.py)10
-rw-r--r--bot/exts/moderation/slowmode.py (renamed from bot/cogs/moderation/slowmode.py)7
-rw-r--r--bot/exts/moderation/verification.py (renamed from bot/cogs/verification.py)17
-rw-r--r--bot/exts/moderation/watchchannels/__init__.py0
-rw-r--r--bot/exts/moderation/watchchannels/_watchchannel.py (renamed from bot/cogs/watchchannels/watchchannel.py)6
-rw-r--r--bot/exts/moderation/watchchannels/bigbrother.py (renamed from bot/cogs/watchchannels/bigbrother.py)22
-rw-r--r--bot/exts/moderation/watchchannels/talentpool.py (renamed from bot/cogs/watchchannels/talentpool.py)26
-rw-r--r--bot/exts/utils/__init__.py0
-rw-r--r--bot/exts/utils/bot.py (renamed from bot/cogs/bot.py)15
-rw-r--r--bot/exts/utils/clean.py (renamed from bot/cogs/clean.py)19
-rw-r--r--bot/exts/utils/eval.py (renamed from bot/cogs/eval.py)7
-rw-r--r--bot/exts/utils/extensions.py (renamed from bot/cogs/extensions.py)72
-rw-r--r--bot/exts/utils/jams.py (renamed from bot/cogs/jams.py)3
-rw-r--r--bot/exts/utils/reminders.py (renamed from bot/cogs/reminders.py)10
-rw-r--r--bot/exts/utils/snekbox.py (renamed from bot/cogs/snekbox.py)1
-rw-r--r--bot/exts/utils/utils.py (renamed from bot/cogs/utils.py)6
-rw-r--r--bot/rules/__init__.py1
-rw-r--r--bot/rules/everyone_ping.py41
-rw-r--r--bot/utils/checks.py49
-rw-r--r--bot/utils/extensions.py34
-rw-r--r--config-default.yml24
-rw-r--r--tests/bot/exts/__init__.py0
-rw-r--r--tests/bot/exts/backend/__init__.py0
-rw-r--r--tests/bot/exts/backend/sync/__init__.py0
-rw-r--r--tests/bot/exts/backend/sync/test_base.py (renamed from tests/bot/cogs/sync/test_base.py)2
-rw-r--r--tests/bot/exts/backend/sync/test_cog.py (renamed from tests/bot/cogs/sync/test_cog.py)17
-rw-r--r--tests/bot/exts/backend/sync/test_roles.py (renamed from tests/bot/cogs/sync/test_roles.py)2
-rw-r--r--tests/bot/exts/backend/sync/test_users.py (renamed from tests/bot/cogs/sync/test_users.py)2
-rw-r--r--tests/bot/exts/backend/test_logging.py (renamed from tests/bot/cogs/test_logging.py)6
-rw-r--r--tests/bot/exts/filters/__init__.py0
-rw-r--r--tests/bot/exts/filters/test_antimalware.py (renamed from tests/bot/cogs/test_antimalware.py)2
-rw-r--r--tests/bot/exts/filters/test_antispam.py (renamed from tests/bot/cogs/test_antispam.py)2
-rw-r--r--tests/bot/exts/filters/test_security.py (renamed from tests/bot/cogs/test_security.py)2
-rw-r--r--tests/bot/exts/filters/test_token_remover.py (renamed from tests/bot/cogs/test_token_remover.py)22
-rw-r--r--tests/bot/exts/info/__init__.py0
-rw-r--r--tests/bot/exts/info/test_information.py (renamed from tests/bot/cogs/test_information.py)20
-rw-r--r--tests/bot/exts/moderation/__init__.py0
-rw-r--r--tests/bot/exts/moderation/infraction/__init__.py0
-rw-r--r--tests/bot/exts/moderation/infraction/test_infractions.py (renamed from tests/bot/cogs/moderation/test_infractions.py)8
-rw-r--r--tests/bot/exts/moderation/infraction/test_utils.py359
-rw-r--r--tests/bot/exts/moderation/test_incidents.py (renamed from tests/bot/cogs/moderation/test_incidents.py)66
-rw-r--r--tests/bot/exts/moderation/test_modlog.py (renamed from tests/bot/cogs/moderation/test_modlog.py)2
-rw-r--r--tests/bot/exts/moderation/test_silence.py (renamed from tests/bot/cogs/moderation/test_silence.py)20
-rw-r--r--tests/bot/exts/moderation/test_slowmode.py (renamed from tests/bot/cogs/test_slowmode.py)14
-rw-r--r--tests/bot/exts/test_cogs.py (renamed from tests/bot/cogs/test_cogs.py)7
-rw-r--r--tests/bot/exts/utils/__init__.py0
-rw-r--r--tests/bot/exts/utils/test_jams.py (renamed from tests/bot/cogs/test_jams.py)2
-rw-r--r--tests/bot/exts/utils/test_snekbox.py (renamed from tests/bot/cogs/test_snekbox.py)16
-rw-r--r--tests/bot/utils/test_checks.py44
97 files changed, 901 insertions, 506 deletions
diff --git a/bot/__main__.py b/bot/__main__.py
index fe2cf90e6..8770ac31b 100644
--- a/bot/__main__.py
+++ b/bot/__main__.py
@@ -9,7 +9,9 @@ from sentry_sdk.integrations.redis import RedisIntegration
from bot import constants, patches
from bot.bot import Bot
+from bot.utils.extensions import EXTENSIONS
+# Set up Sentry.
sentry_logging = LoggingIntegration(
level=logging.DEBUG,
event_level=logging.WARNING
@@ -24,6 +26,7 @@ sentry_sdk.init(
]
)
+# Instantiate the bot.
allowed_roles = [discord.Object(id_) for id_ in constants.MODERATION_ROLES]
bot = Bot(
command_prefix=when_mentioned_or(constants.Bot.prefix),
@@ -33,50 +36,13 @@ bot = Bot(
allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles)
)
-# Internal/debug
-bot.load_extension("bot.cogs.config_verifier")
-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")
+# Load extensions.
+extensions = set(EXTENSIONS) # Create a mutable copy.
+if not constants.HelpChannels.enable:
+ extensions.remove("bot.exts.help_channels")
-# Commands, etc
-bot.load_extension("bot.cogs.antimalware")
-bot.load_extension("bot.cogs.antispam")
-bot.load_extension("bot.cogs.bot")
-bot.load_extension("bot.cogs.clean")
-bot.load_extension("bot.cogs.doc")
-bot.load_extension("bot.cogs.extensions")
-bot.load_extension("bot.cogs.help")
-bot.load_extension("bot.cogs.verification")
-
-# Feature cogs
-bot.load_extension("bot.cogs.alias")
-bot.load_extension("bot.cogs.defcon")
-bot.load_extension("bot.cogs.dm_relay")
-bot.load_extension("bot.cogs.duck_pond")
-bot.load_extension("bot.cogs.eval")
-bot.load_extension("bot.cogs.filter_lists")
-bot.load_extension("bot.cogs.information")
-bot.load_extension("bot.cogs.jams")
-bot.load_extension("bot.cogs.moderation")
-bot.load_extension("bot.cogs.off_topic_names")
-bot.load_extension("bot.cogs.python_news")
-bot.load_extension("bot.cogs.reddit")
-bot.load_extension("bot.cogs.reminders")
-bot.load_extension("bot.cogs.site")
-bot.load_extension("bot.cogs.snekbox")
-bot.load_extension("bot.cogs.source")
-bot.load_extension("bot.cogs.stats")
-bot.load_extension("bot.cogs.sync")
-bot.load_extension("bot.cogs.tags")
-bot.load_extension("bot.cogs.token_remover")
-bot.load_extension("bot.cogs.utils")
-bot.load_extension("bot.cogs.watchchannels")
-bot.load_extension("bot.cogs.webhook_remover")
-
-if constants.HelpChannels.enable:
- bot.load_extension("bot.cogs.help_channels")
+for extension in extensions:
+ bot.load_extension(extension)
# Apply `message_edited_at` patch if discord.py did not yet release a bug fix.
if not hasattr(discord.message.Message, '_handle_edited_timestamp'):
diff --git a/bot/cogs/moderation/__init__.py b/bot/cogs/moderation/__init__.py
deleted file mode 100644
index 995187ef0..000000000
--- a/bot/cogs/moderation/__init__.py
+++ /dev/null
@@ -1,19 +0,0 @@
-from bot.bot import Bot
-from .incidents import Incidents
-from .infractions import Infractions
-from .management import ModManagement
-from .modlog import ModLog
-from .silence import Silence
-from .slowmode import Slowmode
-from .superstarify import Superstarify
-
-
-def setup(bot: Bot) -> None:
- """Load the Incidents, Infractions, ModManagement, ModLog, Silence, Slowmode and Superstarify cogs."""
- bot.add_cog(Incidents(bot))
- bot.add_cog(Infractions(bot))
- bot.add_cog(ModLog(bot))
- bot.add_cog(ModManagement(bot))
- bot.add_cog(Silence(bot))
- bot.add_cog(Slowmode(bot))
- bot.add_cog(Superstarify(bot))
diff --git a/bot/cogs/sync/__init__.py b/bot/cogs/sync/__init__.py
deleted file mode 100644
index fe7df4e9b..000000000
--- a/bot/cogs/sync/__init__.py
+++ /dev/null
@@ -1,7 +0,0 @@
-from bot.bot import Bot
-from .cog import Sync
-
-
-def setup(bot: Bot) -> None:
- """Load the Sync cog."""
- bot.add_cog(Sync(bot))
diff --git a/bot/cogs/watchchannels/__init__.py b/bot/cogs/watchchannels/__init__.py
deleted file mode 100644
index 69d118df6..000000000
--- a/bot/cogs/watchchannels/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from bot.bot import Bot
-from .bigbrother import BigBrother
-from .talentpool import TalentPool
-
-
-def setup(bot: Bot) -> None:
- """Load the BigBrother and TalentPool cogs."""
- bot.add_cog(BigBrother(bot))
- bot.add_cog(TalentPool(bot))
diff --git a/bot/constants.py b/bot/constants.py
index aec0ffce3..0cb076d5c 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -217,6 +217,7 @@ class Filter(metaclass=YAMLGetter):
filter_zalgo: bool
filter_invites: bool
filter_domains: bool
+ filter_everyone_ping: bool
watch_regex: bool
watch_rich_embeds: bool
@@ -224,6 +225,7 @@ class Filter(metaclass=YAMLGetter):
notify_user_zalgo: bool
notify_user_invites: bool
notify_user_domains: bool
+ notify_user_everyone_ping: bool
ping_everyone: bool
offensive_msg_delete_days: int
diff --git a/bot/decorators.py b/bot/decorators.py
index 500197c89..2518124da 100644
--- a/bot/decorators.py
+++ b/bot/decorators.py
@@ -6,13 +6,12 @@ from functools import wraps
from typing import Callable, Container, Optional, Union
from weakref import WeakValueDictionary
-from discord import Colour, Embed, Member
-from discord.errors import NotFound
+from discord import Colour, Embed, Member, NotFound
from discord.ext import commands
from discord.ext.commands import Cog, Context
from bot.constants import Channels, ERROR_REPLIES, RedirectOutput
-from bot.utils.checks import in_whitelist_check, with_role_check, without_role_check
+from bot.utils.checks import in_whitelist_check
log = logging.getLogger(__name__)
@@ -45,18 +44,22 @@ def in_whitelist(
return commands.check(predicate)
-def with_role(*role_ids: int) -> Callable:
- """Returns True if the user has any one of the roles in role_ids."""
- async def predicate(ctx: Context) -> bool:
- """With role checker predicate."""
- return with_role_check(ctx, *role_ids)
- return commands.check(predicate)
-
+def has_no_roles(*roles: Union[str, int]) -> Callable:
+ """
+ Returns True if the user does not have any of the roles specified.
-def without_role(*role_ids: int) -> Callable:
- """Returns True if the user does not have any of the roles in role_ids."""
+ `roles` are the names or IDs of the disallowed roles.
+ """
async def predicate(ctx: Context) -> bool:
- return without_role_check(ctx, *role_ids)
+ try:
+ await commands.has_any_role(*roles).predicate(ctx)
+ except commands.MissingAnyRole:
+ return True
+ else:
+ # This error is never shown to users, so don't bother trying to make it too pretty.
+ roles_ = ", ".join(f"'{item}'" for item in roles)
+ raise commands.CheckFailure(f"You have at least one of the disallowed roles: {roles_}")
+
return commands.check(predicate)
diff --git a/bot/cogs/__init__.py b/bot/exts/__init__.py
index e69de29bb..e69de29bb 100644
--- a/bot/cogs/__init__.py
+++ b/bot/exts/__init__.py
diff --git a/tests/bot/cogs/__init__.py b/bot/exts/backend/__init__.py
index e69de29bb..e69de29bb 100644
--- a/tests/bot/cogs/__init__.py
+++ b/bot/exts/backend/__init__.py
diff --git a/bot/cogs/alias.py b/bot/exts/backend/alias.py
index c6ba8d6f3..c6ba8d6f3 100644
--- a/bot/cogs/alias.py
+++ b/bot/exts/backend/alias.py
diff --git a/bot/cogs/config_verifier.py b/bot/exts/backend/config_verifier.py
index d72c6c22e..d72c6c22e 100644
--- a/bot/cogs/config_verifier.py
+++ b/bot/exts/backend/config_verifier.py
diff --git a/bot/cogs/error_handler.py b/bot/exts/backend/error_handler.py
index f9d4de638..f9d4de638 100644
--- a/bot/cogs/error_handler.py
+++ b/bot/exts/backend/error_handler.py
diff --git a/bot/cogs/logging.py b/bot/exts/backend/logging.py
index 94fa2b139..94fa2b139 100644
--- a/bot/cogs/logging.py
+++ b/bot/exts/backend/logging.py
diff --git a/bot/exts/backend/sync/__init__.py b/bot/exts/backend/sync/__init__.py
new file mode 100644
index 000000000..829098f79
--- /dev/null
+++ b/bot/exts/backend/sync/__init__.py
@@ -0,0 +1,8 @@
+from bot.bot import Bot
+
+
+def setup(bot: Bot) -> None:
+ """Load the Sync cog."""
+ # Defer import to reduce side effects from importing the sync package.
+ from bot.exts.backend.sync._cog import Sync
+ bot.add_cog(Sync(bot))
diff --git a/bot/cogs/sync/cog.py b/bot/exts/backend/sync/_cog.py
index 5ace957e7..6e85e2b7d 100644
--- a/bot/cogs/sync/cog.py
+++ b/bot/exts/backend/sync/_cog.py
@@ -8,7 +8,7 @@ from discord.ext.commands import Cog, Context
from bot import constants
from bot.api import ResponseCodeError
from bot.bot import Bot
-from bot.cogs.sync import syncers
+from bot.exts.backend.sync import _syncers
log = logging.getLogger(__name__)
@@ -18,8 +18,8 @@ class Sync(Cog):
def __init__(self, bot: Bot) -> None:
self.bot = bot
- self.role_syncer = syncers.RoleSyncer(self.bot)
- self.user_syncer = syncers.UserSyncer(self.bot)
+ self.role_syncer = _syncers.RoleSyncer(self.bot)
+ self.user_syncer = _syncers.UserSyncer(self.bot)
self.bot.loop.create_task(self.sync_guild())
diff --git a/bot/cogs/sync/syncers.py b/bot/exts/backend/sync/_syncers.py
index f7ba811bc..f7ba811bc 100644
--- a/bot/cogs/sync/syncers.py
+++ b/bot/exts/backend/sync/_syncers.py
diff --git a/tests/bot/cogs/moderation/__init__.py b/bot/exts/filters/__init__.py
index e69de29bb..e69de29bb 100644
--- a/tests/bot/cogs/moderation/__init__.py
+++ b/bot/exts/filters/__init__.py
diff --git a/bot/cogs/antimalware.py b/bot/exts/filters/antimalware.py
index 7894ec48f..7894ec48f 100644
--- a/bot/cogs/antimalware.py
+++ b/bot/exts/filters/antimalware.py
diff --git a/bot/cogs/antispam.py b/bot/exts/filters/antispam.py
index 3ad487d8c..f2a2689e1 100644
--- a/bot/cogs/antispam.py
+++ b/bot/exts/filters/antispam.py
@@ -11,7 +11,6 @@ from discord.ext.commands import Cog
from bot import rules
from bot.bot import Bot
-from bot.cogs.moderation import ModLog
from bot.constants import (
AntiSpam as AntiSpamConfig, Channels,
Colours, DEBUG_MODE, Event, Filter,
@@ -19,6 +18,7 @@ from bot.constants import (
STAFF_ROLES,
)
from bot.converters import Duration
+from bot.exts.moderation.modlog import ModLog
from bot.utils.messages import send_attachments
@@ -36,9 +36,6 @@ RULE_FUNCTION_MAPPING = {
'mentions': rules.apply_mentions,
'newlines': rules.apply_newlines,
'role_mentions': rules.apply_role_mentions,
- # the everyone filter is temporarily disabled until
- # it has been improved.
- # 'everyone_ping': rules.apply_everyone_ping,
}
diff --git a/bot/cogs/filter_lists.py b/bot/exts/filters/filter_lists.py
index c15adc461..232c1e48b 100644
--- a/bot/cogs/filter_lists.py
+++ b/bot/exts/filters/filter_lists.py
@@ -2,14 +2,13 @@ import logging
from typing import Optional
from discord import Colour, Embed
-from discord.ext.commands import BadArgument, Cog, Context, IDConverter, group
+from discord.ext.commands import BadArgument, Cog, Context, IDConverter, group, has_any_role
from bot import constants
from bot.api import ResponseCodeError
from bot.bot import Bot
from bot.converters import ValidDiscordServerInvite, ValidFilterListType
from bot.pagination import LinePaginator
-from bot.utils.checks import with_role_check
log = logging.getLogger(__name__)
@@ -263,9 +262,9 @@ class FilterLists(Cog):
"""Syncs both allowlists and denylists with the API."""
await self._sync_data(ctx)
- def cog_check(self, ctx: Context) -> bool:
+ async def cog_check(self, ctx: Context) -> bool:
"""Only allow moderators to invoke the commands in this cog."""
- return with_role_check(ctx, *constants.MODERATION_ROLES)
+ return await has_any_role(*constants.MODERATION_ROLES).predicate(ctx)
def setup(bot: Bot) -> None:
diff --git a/bot/cogs/filtering.py b/bot/exts/filters/filtering.py
index 99b659bff..3bf0d88a0 100644
--- a/bot/cogs/filtering.py
+++ b/bot/exts/filters/filtering.py
@@ -13,11 +13,11 @@ from discord.utils import escape_markdown
from bot.api import ResponseCodeError
from bot.bot import Bot
-from bot.cogs.moderation import ModLog
from bot.constants import (
- Channels, Colours,
- Filter, Icons, URLs
+ Channels, Colours, Filter,
+ Guild, Icons, URLs
)
+from bot.exts.moderation.modlog import ModLog
from bot.utils.redis_cache import RedisCache
from bot.utils.regex import INVITE_RE
from bot.utils.scheduling import Scheduler
@@ -25,6 +25,12 @@ from bot.utils.scheduling import Scheduler
log = logging.getLogger(__name__)
# Regular expressions
+CODE_BLOCK_RE = re.compile(
+ r"(?P<delim>``?)[^`]+?(?P=delim)(?!`+)" # Inline codeblock
+ r"|```(.+?)```", # Multiline codeblock
+ re.DOTALL | re.MULTILINE
+)
+EVERYONE_PING_RE = re.compile(rf"@everyone|<@&{Guild.id}>|@here")
SPOILER_RE = re.compile(r"(\|\|.+?\|\|)", re.DOTALL)
URL_RE = re.compile(r"(https?://[^\s]+)", flags=re.IGNORECASE)
ZALGO_RE = re.compile(r"[\u0300-\u036F\u0489]")
@@ -82,6 +88,19 @@ class Filtering(Cog):
),
"schedule_deletion": False
},
+ "filter_everyone_ping": {
+ "enabled": Filter.filter_everyone_ping,
+ "function": self._has_everyone_ping,
+ "type": "filter",
+ "content_only": True,
+ "user_notification": Filter.notify_user_everyone_ping,
+ "notification_msg": (
+ "Please don't try to ping `@everyone` or `@here`. "
+ f"Your message has been removed. {staff_mistake_str}"
+ ),
+ "schedule_deletion": False,
+ "ping_everyone": False
+ },
"watch_regex": {
"enabled": Filter.watch_regex,
"function": self._has_watch_regex_match,
@@ -332,6 +351,9 @@ class Filtering(Cog):
log.debug(message)
+ # Allow specific filters to override ping_everyone
+ ping_everyone = Filter.ping_everyone and _filter.get("ping_everyone", True)
+
# Send pretty mod log embed to mod-alerts
await self.mod_log.send_log_message(
icon_url=Icons.filtering,
@@ -340,7 +362,7 @@ class Filtering(Cog):
text=message,
thumbnail=msg.author.avatar_url_as(static_format="png"),
channel_id=Channels.mod_alerts,
- ping_everyone=Filter.ping_everyone if not is_private else False,
+ ping_everyone=ping_everyone if not is_private else False,
additional_embeds=additional_embeds,
additional_embeds_msg=additional_embeds_msg
)
@@ -528,6 +550,16 @@ class Filtering(Cog):
return False
return False
+ @staticmethod
+ async def _has_everyone_ping(text: str) -> bool:
+ """Determines if `msg` contains an @everyone or @here ping outside of a codeblock."""
+ # First pass to avoid running re.sub on every message
+ if not EVERYONE_PING_RE.search(text):
+ return False
+
+ content_without_codeblocks = CODE_BLOCK_RE.sub("", text)
+ return bool(EVERYONE_PING_RE.search(content_without_codeblocks))
+
async def notify_member(self, filtered_member: Member, reason: str, channel: TextChannel) -> None:
"""
Notify filtered_member about a moderation action with the reason str.
diff --git a/bot/cogs/security.py b/bot/exts/filters/security.py
index c680c5e27..c680c5e27 100644
--- a/bot/cogs/security.py
+++ b/bot/exts/filters/security.py
diff --git a/bot/cogs/token_remover.py b/bot/exts/filters/token_remover.py
index ef979f222..0eda3dc6a 100644
--- a/bot/cogs/token_remover.py
+++ b/bot/exts/filters/token_remover.py
@@ -9,8 +9,8 @@ from discord.ext.commands import Cog
from bot import utils
from bot.bot import Bot
-from bot.cogs.moderation import ModLog
from bot.constants import Channels, Colours, Event, Icons
+from bot.exts.moderation.modlog import ModLog
log = logging.getLogger(__name__)
diff --git a/bot/cogs/webhook_remover.py b/bot/exts/filters/webhook_remover.py
index 5812da87c..ca126ebf5 100644
--- a/bot/cogs/webhook_remover.py
+++ b/bot/exts/filters/webhook_remover.py
@@ -5,8 +5,8 @@ from discord import Colour, Message, NotFound
from discord.ext.commands import Cog
from bot.bot import Bot
-from bot.cogs.moderation.modlog import ModLog
from bot.constants import Channels, Colours, Event, Icons
+from bot.exts.moderation.modlog import ModLog
WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(?:app)?\.com/api/webhooks/\d+/)\S+/?", re.IGNORECASE)
diff --git a/tests/bot/cogs/sync/__init__.py b/bot/exts/fun/__init__.py
index e69de29bb..e69de29bb 100644
--- a/tests/bot/cogs/sync/__init__.py
+++ b/bot/exts/fun/__init__.py
diff --git a/bot/cogs/duck_pond.py b/bot/exts/fun/duck_pond.py
index 2758de8ab..2758de8ab 100644
--- a/bot/cogs/duck_pond.py
+++ b/bot/exts/fun/duck_pond.py
diff --git a/bot/cogs/off_topic_names.py b/bot/exts/fun/off_topic_names.py
index ce95450e0..b9d235fa2 100644
--- a/bot/cogs/off_topic_names.py
+++ b/bot/exts/fun/off_topic_names.py
@@ -4,13 +4,12 @@ import logging
from datetime import datetime, timedelta
from discord import Colour, Embed
-from discord.ext.commands import Cog, Context, group
+from discord.ext.commands import Cog, Context, group, has_any_role
from bot.api import ResponseCodeError
from bot.bot import Bot
from bot.constants import Channels, MODERATION_ROLES
from bot.converters import OffTopicName
-from bot.decorators import with_role
from bot.pagination import LinePaginator
CHANNELS = (Channels.off_topic_0, Channels.off_topic_1, Channels.off_topic_2)
@@ -67,13 +66,13 @@ class OffTopicNames(Cog):
self.updater_task = self.bot.loop.create_task(coro)
@group(name='otname', aliases=('otnames', 'otn'), invoke_without_command=True)
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def otname_group(self, ctx: Context) -> None:
"""Add or list items from the off-topic channel name rotation."""
await ctx.send_help(ctx.command)
@otname_group.command(name='add', aliases=('a',))
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def add_command(self, ctx: Context, *, name: OffTopicName) -> None:
"""
Adds a new off-topic name to the rotation.
@@ -96,7 +95,7 @@ class OffTopicNames(Cog):
await self._add_name(ctx, name)
@otname_group.command(name='forceadd', aliases=('fa',))
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def force_add_command(self, ctx: Context, *, name: OffTopicName) -> None:
"""Forcefully adds a new off-topic name to the rotation."""
await self._add_name(ctx, name)
@@ -109,7 +108,7 @@ class OffTopicNames(Cog):
await ctx.send(f":ok_hand: Added `{name}` to the names list.")
@otname_group.command(name='delete', aliases=('remove', 'rm', 'del', 'd'))
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def delete_command(self, ctx: Context, *, name: OffTopicName) -> None:
"""Removes a off-topic name from the rotation."""
await self.bot.api_client.delete(f'bot/off-topic-channel-names/{name}')
@@ -118,7 +117,7 @@ class OffTopicNames(Cog):
await ctx.send(f":ok_hand: Removed `{name}` from the names list.")
@otname_group.command(name='list', aliases=('l',))
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def list_command(self, ctx: Context) -> None:
"""
Lists all currently known off-topic channel names in a paginator.
@@ -138,7 +137,7 @@ class OffTopicNames(Cog):
await ctx.send(embed=embed)
@otname_group.command(name='search', aliases=('s',))
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def search_command(self, ctx: Context, *, query: OffTopicName) -> None:
"""Search for an off-topic name."""
result = await self.bot.api_client.get('bot/off-topic-channel-names')
diff --git a/bot/cogs/help_channels.py b/bot/exts/help_channels.py
index 0f9cac89e..17142071f 100644
--- a/bot/cogs/help_channels.py
+++ b/bot/exts/help_channels.py
@@ -14,7 +14,6 @@ from discord.ext import commands
from bot import constants
from bot.bot import Bot
from bot.utils import RedisCache
-from bot.utils.checks import with_role_check
from bot.utils.scheduling import Scheduler
log = logging.getLogger(__name__)
@@ -196,12 +195,12 @@ class HelpChannels(commands.Cog):
return True
log.trace(f"{ctx.author} is not the help channel claimant, checking roles.")
- role_check = with_role_check(ctx, *constants.HelpChannels.cmd_whitelist)
+ has_role = await commands.has_any_role(*constants.HelpChannels.cmd_whitelist).predicate(ctx)
- if role_check:
+ if has_role:
self.bot.stats.incr("help.dormant_invoke.staff")
- return role_check
+ return has_role
@commands.command(name="close", aliases=["dormant", "solved"], enabled=False)
async def close_command(self, ctx: commands.Context) -> None:
diff --git a/bot/exts/info/__init__.py b/bot/exts/info/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/bot/exts/info/__init__.py
diff --git a/bot/cogs/doc.py b/bot/exts/info/doc.py
index 30c793c75..e50b9b32b 100644
--- a/bot/cogs/doc.py
+++ b/bot/exts/info/doc.py
@@ -21,7 +21,6 @@ from urllib3.exceptions import ProtocolError
from bot.bot import Bot
from bot.constants import MODERATION_ROLES, RedirectOutput
from bot.converters import ValidPythonIdentifier, ValidURL
-from bot.decorators import with_role
from bot.pagination import LinePaginator
from bot.utils.messages import wait_for_deletion
@@ -396,7 +395,7 @@ class Doc(commands.Cog):
await wait_for_deletion(msg, (ctx.author.id,), client=self.bot)
@docs_group.command(name='set', aliases=('s',))
- @with_role(*MODERATION_ROLES)
+ @commands.has_any_role(*MODERATION_ROLES)
async def set_command(
self, ctx: commands.Context, package_name: ValidPythonIdentifier,
base_url: ValidURL, inventory_url: InventoryURL
@@ -433,7 +432,7 @@ class Doc(commands.Cog):
await ctx.send(f"Added package `{package_name}` to database and refreshed inventory.")
@docs_group.command(name='delete', aliases=('remove', 'rm', 'd'))
- @with_role(*MODERATION_ROLES)
+ @commands.has_any_role(*MODERATION_ROLES)
async def delete_command(self, ctx: commands.Context, package_name: ValidPythonIdentifier) -> None:
"""
Removes the specified package from the database.
@@ -450,7 +449,7 @@ class Doc(commands.Cog):
await ctx.send(f"Successfully deleted `{package_name}` and refreshed inventory.")
@docs_group.command(name="refresh", aliases=("rfsh", "r"))
- @with_role(*MODERATION_ROLES)
+ @commands.has_any_role(*MODERATION_ROLES)
async def refresh_command(self, ctx: commands.Context) -> None:
"""Refresh inventories and send differences to channel."""
old_inventories = set(self.base_urls)
diff --git a/bot/cogs/help.py b/bot/exts/info/help.py
index 99d503f5c..99d503f5c 100644
--- a/bot/cogs/help.py
+++ b/bot/exts/info/help.py
diff --git a/bot/cogs/information.py b/bot/exts/info/information.py
index 55ecb2836..581b3a227 100644
--- a/bot/cogs/information.py
+++ b/bot/exts/info/information.py
@@ -8,18 +8,19 @@ from typing import Any, Mapping, Optional, Tuple, Union
from discord import ChannelType, Colour, CustomActivity, Embed, Guild, Member, Message, Role, Status, utils
from discord.abc import GuildChannel
-from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group
+from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role
from discord.utils import escape_markdown
from bot import constants
from bot.bot import Bot
-from bot.decorators import in_whitelist, with_role
+from bot.decorators import in_whitelist
from bot.pagination import LinePaginator
-from bot.utils.checks import InWhitelistCheckFailure, cooldown_with_role_bypass, with_role_check
+from bot.utils.checks import InWhitelistCheckFailure, cooldown_with_role_bypass, has_no_roles_check
from bot.utils.time import time_since
log = logging.getLogger(__name__)
+
STATUS_EMOTES = {
Status.offline: constants.Emojis.status_offline,
Status.dnd: constants.Emojis.status_dnd,
@@ -76,7 +77,7 @@ class Information(Cog):
channel_type_list = sorted(channel_type_list)
return "\n".join(channel_type_list)
- @with_role(*constants.MODERATION_ROLES)
+ @has_any_role(*constants.MODERATION_ROLES)
@command(name="roles")
async def roles_info(self, ctx: Context) -> None:
"""Returns a list of all roles and their corresponding IDs."""
@@ -96,7 +97,7 @@ class Information(Cog):
await LinePaginator.paginate(role_list, ctx, embed, empty=False)
- @with_role(*constants.MODERATION_ROLES)
+ @has_any_role(*constants.MODERATION_ROLES)
@command(name="role")
async def role_info(self, ctx: Context, *roles: Union[Role, str]) -> None:
"""
@@ -197,12 +198,12 @@ class Information(Cog):
user = ctx.author
# Do a role check if this is being executed on someone other than the caller
- elif user != ctx.author and not with_role_check(ctx, *constants.MODERATION_ROLES):
+ elif user != ctx.author and await has_no_roles_check(ctx, *constants.MODERATION_ROLES):
await ctx.send("You may not use this command on users other than yourself.")
return
# Non-staff may only do this in #bot-commands
- if not with_role_check(ctx, *constants.STAFF_ROLES):
+ if await has_no_roles_check(ctx, *constants.STAFF_ROLES):
if not ctx.channel.id == constants.Channels.bot_commands:
raise InWhitelistCheckFailure(constants.Channels.bot_commands)
diff --git a/bot/cogs/python_news.py b/bot/exts/info/python_news.py
index 0ab5738a4..0ab5738a4 100644
--- a/bot/cogs/python_news.py
+++ b/bot/exts/info/python_news.py
diff --git a/bot/cogs/reddit.py b/bot/exts/info/reddit.py
index 5d9e2c20b..635162308 100644
--- a/bot/cogs/reddit.py
+++ b/bot/exts/info/reddit.py
@@ -8,14 +8,13 @@ from typing import List
from aiohttp import BasicAuth, ClientError
from discord import Colour, Embed, TextChannel
-from discord.ext.commands import Cog, Context, group
+from discord.ext.commands import Cog, Context, group, has_any_role
from discord.ext.tasks import loop
from discord.utils import escape_markdown
from bot.bot import Bot
from bot.constants import Channels, ERROR_REPLIES, Emojis, Reddit as RedditConfig, STAFF_ROLES, Webhooks
from bot.converters import Subreddit
-from bot.decorators import with_role
from bot.pagination import LinePaginator
from bot.utils.messages import sub_clyde
@@ -282,7 +281,7 @@ class Reddit(Cog):
await ctx.send(content=f"Here are this week's top {subreddit} posts!", embed=embed)
- @with_role(*STAFF_ROLES)
+ @has_any_role(*STAFF_ROLES)
@reddit_group.command(name="subreddits", aliases=("subs",))
async def subreddits_command(self, ctx: Context) -> None:
"""Send a paginated embed of all the subreddits we're relaying."""
diff --git a/bot/cogs/site.py b/bot/exts/info/site.py
index 2d3a3d9f3..2d3a3d9f3 100644
--- a/bot/cogs/site.py
+++ b/bot/exts/info/site.py
diff --git a/bot/cogs/source.py b/bot/exts/info/source.py
index 205e0ba81..205e0ba81 100644
--- a/bot/cogs/source.py
+++ b/bot/exts/info/source.py
diff --git a/bot/cogs/stats.py b/bot/exts/info/stats.py
index d42f55466..d42f55466 100644
--- a/bot/cogs/stats.py
+++ b/bot/exts/info/stats.py
diff --git a/bot/cogs/tags.py b/bot/exts/info/tags.py
index d01647312..d01647312 100644
--- a/bot/cogs/tags.py
+++ b/bot/exts/info/tags.py
diff --git a/bot/exts/moderation/__init__.py b/bot/exts/moderation/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/bot/exts/moderation/__init__.py
diff --git a/bot/cogs/defcon.py b/bot/exts/moderation/defcon.py
index 9087ac454..3bf462877 100644
--- a/bot/cogs/defcon.py
+++ b/bot/exts/moderation/defcon.py
@@ -6,12 +6,11 @@ from datetime import datetime, timedelta
from enum import Enum
from discord import Colour, Embed, Member
-from discord.ext.commands import Cog, Context, group
+from discord.ext.commands import Cog, Context, group, has_any_role
from bot.bot import Bot
-from bot.cogs.moderation import ModLog
from bot.constants import Channels, Colours, Emojis, Event, Icons, MODERATION_ROLES, Roles
-from bot.decorators import with_role
+from bot.exts.moderation.modlog import ModLog
log = logging.getLogger(__name__)
@@ -119,7 +118,7 @@ class Defcon(Cog):
)
@group(name='defcon', aliases=('dc',), invoke_without_command=True)
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def defcon_group(self, ctx: Context) -> None:
"""Check the DEFCON status or run a subcommand."""
await ctx.send_help(ctx.command)
@@ -163,7 +162,7 @@ class Defcon(Cog):
self.bot.stats.gauge("defcon.threshold", days)
@defcon_group.command(name='enable', aliases=('on', 'e'), root_aliases=("defon",))
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def enable_command(self, ctx: Context) -> None:
"""
Enable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!
@@ -176,7 +175,7 @@ class Defcon(Cog):
await self.update_channel_topic()
@defcon_group.command(name='disable', aliases=('off', 'd'), root_aliases=("defoff",))
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
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
@@ -184,7 +183,7 @@ class Defcon(Cog):
await self.update_channel_topic()
@defcon_group.command(name='status', aliases=('s',))
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def status_command(self, ctx: Context) -> None:
"""Check the current status of DEFCON mode."""
embed = Embed(
@@ -196,7 +195,7 @@ class Defcon(Cog):
await ctx.send(embed=embed)
@defcon_group.command(name='days')
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
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/dm_relay.py b/bot/exts/moderation/dm_relay.py
index 0d8f340b4..7a3fe49bb 100644
--- a/bot/cogs/dm_relay.py
+++ b/bot/exts/moderation/dm_relay.py
@@ -10,7 +10,7 @@ from bot import constants
from bot.bot import Bot
from bot.converters import UserMentionOrID
from bot.utils import RedisCache
-from bot.utils.checks import in_whitelist_check, with_role_check
+from bot.utils.checks import in_whitelist_check
from bot.utils.messages import send_attachments
from bot.utils.webhooks import send_webhook
@@ -105,10 +105,10 @@ class DMRelay(Cog):
except discord.HTTPException:
log.exception("Failed to send an attachment to the webhook")
- def cog_check(self, ctx: commands.Context) -> bool:
+ async def cog_check(self, ctx: commands.Context) -> bool:
"""Only allow moderators to invoke the commands in this cog."""
checks = [
- with_role_check(ctx, *constants.MODERATION_ROLES),
+ await commands.has_any_role(*constants.MODERATION_ROLES).predicate(ctx),
in_whitelist_check(
ctx,
channels=[constants.Channels.dm_log],
diff --git a/bot/cogs/moderation/incidents.py b/bot/exts/moderation/incidents.py
index 3605ab1d2..e49913552 100644
--- a/bot/cogs/moderation/incidents.py
+++ b/bot/exts/moderation/incidents.py
@@ -405,3 +405,8 @@ class Incidents(Cog):
"""Pass `message` to `add_signals` if and only if it satisfies `is_incident`."""
if is_incident(message):
await add_signals(message)
+
+
+def setup(bot: Bot) -> None:
+ """Load the Incidents cog."""
+ bot.add_cog(Incidents(bot))
diff --git a/bot/exts/moderation/infraction/__init__.py b/bot/exts/moderation/infraction/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/bot/exts/moderation/infraction/__init__.py
diff --git a/bot/cogs/moderation/scheduler.py b/bot/exts/moderation/infraction/_scheduler.py
index 051f6c52c..cf48ef2ac 100644
--- a/bot/cogs/moderation/scheduler.py
+++ b/bot/exts/moderation/infraction/_scheduler.py
@@ -13,11 +13,11 @@ from bot import constants
from bot.api import ResponseCodeError
from bot.bot import Bot
from bot.constants import Colours, STAFF_CHANNELS
+from bot.exts.moderation.infraction import _utils
+from bot.exts.moderation.infraction._utils import UserSnowflake
+from bot.exts.moderation.modlog import ModLog
from bot.utils import time
from bot.utils.scheduling import Scheduler
-from . import utils
-from .modlog import ModLog
-from .utils import UserSnowflake
log = logging.getLogger(__name__)
@@ -56,7 +56,7 @@ class InfractionScheduler:
async def reapply_infraction(
self,
- infraction: utils.Infraction,
+ infraction: _utils.Infraction,
apply_coro: t.Optional[t.Awaitable]
) -> None:
"""Reapply an infraction if it's still active or deactivate it if less than 60 sec left."""
@@ -80,13 +80,13 @@ class InfractionScheduler:
async def apply_infraction(
self,
ctx: Context,
- infraction: utils.Infraction,
+ infraction: _utils.Infraction,
user: UserSnowflake,
action_coro: t.Optional[t.Awaitable] = None
) -> None:
"""Apply an infraction to the user, log the infraction, and optionally notify the user."""
infr_type = infraction["type"]
- icon = utils.INFRACTION_ICONS[infr_type][0]
+ icon = _utils.INFRACTION_ICONS[infr_type][0]
reason = infraction["reason"]
expiry = time.format_infraction_with_duration(infraction["expires_at"])
id_ = infraction['id']
@@ -126,7 +126,7 @@ class InfractionScheduler:
log.error(f"Failed to DM {user.id}: could not fetch user (status {e.status})")
else:
# Accordingly display whether the user was successfully notified via DM.
- if await utils.notify_infraction(user, infr_type, expiry, reason, icon):
+ if await _utils.notify_infraction(user, infr_type, expiry, reason, icon):
dm_result = ":incoming_envelope: "
dm_log_text = "\nDM: Sent"
@@ -318,7 +318,7 @@ class InfractionScheduler:
# Send a log message to the mod log.
await self.mod_log.send_log_message(
- icon_url=utils.INFRACTION_ICONS[infr_type][1],
+ icon_url=_utils.INFRACTION_ICONS[infr_type][1],
colour=Colours.soft_green,
title=f"Infraction {log_title}: {infr_type}",
thumbnail=user.avatar_url_as(static_format="png"),
@@ -329,7 +329,7 @@ class InfractionScheduler:
async def deactivate_infraction(
self,
- infraction: utils.Infraction,
+ infraction: _utils.Infraction,
send_log: bool = True
) -> t.Dict[str, str]:
"""
@@ -434,7 +434,7 @@ class InfractionScheduler:
log.trace(f"Sending deactivation mod log for infraction #{id_}.")
await self.mod_log.send_log_message(
- icon_url=utils.INFRACTION_ICONS[type_][1],
+ icon_url=_utils.INFRACTION_ICONS[type_][1],
colour=Colours.soft_green,
title=f"Infraction {log_title}: {type_}",
thumbnail=avatar,
@@ -446,7 +446,7 @@ class InfractionScheduler:
return log_text
@abstractmethod
- async def _pardon_action(self, infraction: utils.Infraction) -> t.Optional[t.Dict[str, str]]:
+ async def _pardon_action(self, infraction: _utils.Infraction) -> t.Optional[t.Dict[str, str]]:
"""
Execute deactivation steps specific to the infraction's type and return a log dict.
@@ -454,7 +454,7 @@ class InfractionScheduler:
"""
raise NotImplementedError
- def schedule_expiration(self, infraction: utils.Infraction) -> None:
+ def schedule_expiration(self, infraction: _utils.Infraction) -> None:
"""
Marks an infraction expired after the delay from time of scheduling to time of expiration.
diff --git a/bot/cogs/moderation/utils.py b/bot/exts/moderation/infraction/_utils.py
index f21272102..1d91964f1 100644
--- a/bot/cogs/moderation/utils.py
+++ b/bot/exts/moderation/infraction/_utils.py
@@ -1,5 +1,4 @@
import logging
-import textwrap
import typing as t
from datetime import datetime
@@ -28,6 +27,18 @@ UserObject = t.Union[discord.Member, discord.User]
UserSnowflake = t.Union[UserObject, discord.Object]
Infraction = t.Dict[str, t.Union[str, int, bool]]
+APPEAL_EMAIL = "[email protected]"
+
+INFRACTION_TITLE = f"Please review our rules over at {RULES_URL}"
+INFRACTION_APPEAL_FOOTER = f"To appeal this infraction, send an e-mail to {APPEAL_EMAIL}"
+INFRACTION_AUTHOR_NAME = "Infraction information"
+
+INFRACTION_DESCRIPTION_TEMPLATE = (
+ "**Type:** {type}\n"
+ "**Expires:** {expires}\n"
+ "**Reason:** {reason}\n"
+)
+
async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]:
"""
@@ -142,25 +153,27 @@ async def notify_infraction(
"""DM a user about their new infraction and return True if the DM is successful."""
log.trace(f"Sending {user} a DM about their {infr_type} infraction.")
- text = textwrap.dedent(f"""
- **Type:** {infr_type.capitalize()}
- **Expires:** {expires_at or "N/A"}
- **Reason:** {reason or "No reason provided."}
- """)
+ text = INFRACTION_DESCRIPTION_TEMPLATE.format(
+ type=infr_type.capitalize(),
+ expires=expires_at or "N/A",
+ reason=reason or "No reason provided."
+ )
+
+ # For case when other fields than reason is too long and this reach limit, then force-shorten string
+ if len(text) > 2048:
+ text = f"{text[:2045]}..."
embed = discord.Embed(
- description=textwrap.shorten(text, width=2048, placeholder="..."),
+ description=text,
colour=Colours.soft_red
)
- embed.set_author(name="Infraction information", icon_url=icon_url, url=RULES_URL)
- embed.title = f"Please review our rules over at {RULES_URL}"
+ embed.set_author(name=INFRACTION_AUTHOR_NAME, icon_url=icon_url, url=RULES_URL)
+ embed.title = INFRACTION_TITLE
embed.url = RULES_URL
if infr_type in APPEALABLE_INFRACTIONS:
- embed.set_footer(
- text="To appeal this infraction, send an e-mail to [email protected]"
- )
+ embed.set_footer(text=INFRACTION_APPEAL_FOOTER)
return await send_private_embed(user, embed)
diff --git a/bot/cogs/moderation/infractions.py b/bot/exts/moderation/infraction/infractions.py
index 8df642428..5fa62d3c4 100644
--- a/bot/cogs/moderation/infractions.py
+++ b/bot/exts/moderation/infraction/infractions.py
@@ -12,10 +12,9 @@ from bot.bot import Bot
from bot.constants import Event
from bot.converters import Expiry, FetchedMember
from bot.decorators import respect_role_hierarchy
-from bot.utils.checks import with_role_check
-from . import utils
-from .scheduler import InfractionScheduler
-from .utils import UserSnowflake
+from bot.exts.moderation.infraction import _utils
+from bot.exts.moderation.infraction._scheduler import InfractionScheduler
+from bot.exts.moderation.infraction._utils import UserSnowflake
log = logging.getLogger(__name__)
@@ -55,7 +54,7 @@ class Infractions(InfractionScheduler, commands.Cog):
@command()
async def warn(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None:
"""Warn a user for the given reason."""
- infraction = await utils.post_infraction(ctx, user, "warning", reason, active=False)
+ infraction = await _utils.post_infraction(ctx, user, "warning", reason, active=False)
if infraction is None:
return
@@ -125,7 +124,7 @@ class Infractions(InfractionScheduler, commands.Cog):
@command(hidden=True)
async def note(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None:
"""Create a private note for a user with the given reason without notifying the user."""
- infraction = await utils.post_infraction(ctx, user, "note", reason, hidden=True, active=False)
+ infraction = await _utils.post_infraction(ctx, user, "note", reason, hidden=True, active=False)
if infraction is None:
return
@@ -213,10 +212,10 @@ class Infractions(InfractionScheduler, commands.Cog):
async def apply_mute(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None:
"""Apply a mute infraction with kwargs passed to `post_infraction`."""
- if await utils.get_active_infraction(ctx, user, "mute"):
+ if await _utils.get_active_infraction(ctx, user, "mute"):
return
- infraction = await utils.post_infraction(ctx, user, "mute", reason, active=True, **kwargs)
+ infraction = await _utils.post_infraction(ctx, user, "mute", reason, active=True, **kwargs)
if infraction is None:
return
@@ -233,7 +232,7 @@ class Infractions(InfractionScheduler, commands.Cog):
@respect_role_hierarchy()
async def apply_kick(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None:
"""Apply a kick infraction with kwargs passed to `post_infraction`."""
- infraction = await utils.post_infraction(ctx, user, "kick", reason, active=False, **kwargs)
+ infraction = await _utils.post_infraction(ctx, user, "kick", reason, active=False, **kwargs)
if infraction is None:
return
@@ -254,7 +253,7 @@ class Infractions(InfractionScheduler, commands.Cog):
"""
# In the case of a permanent ban, we don't need get_active_infractions to tell us if one is active
is_temporary = kwargs.get("expires_at") is not None
- active_infraction = await utils.get_active_infraction(ctx, user, "ban", is_temporary)
+ active_infraction = await _utils.get_active_infraction(ctx, user, "ban", is_temporary)
if active_infraction:
if is_temporary:
@@ -269,7 +268,7 @@ class Infractions(InfractionScheduler, commands.Cog):
log.trace("Old tempban is being replaced by new permaban.")
await self.pardon_infraction(ctx, "ban", user, is_temporary)
- infraction = await utils.post_infraction(ctx, user, "ban", reason, active=True, **kwargs)
+ infraction = await _utils.post_infraction(ctx, user, "ban", reason, active=True, **kwargs)
if infraction is None:
return
@@ -309,11 +308,11 @@ class Infractions(InfractionScheduler, commands.Cog):
await user.remove_roles(self._muted_role, reason=reason)
# DM the user about the expiration.
- notified = await utils.notify_pardon(
+ notified = await _utils.notify_pardon(
user=user,
title="You have been unmuted",
content="You may now send messages in the server.",
- icon_url=utils.INFRACTION_ICONS["mute"][1]
+ icon_url=_utils.INFRACTION_ICONS["mute"][1]
)
log_text["Member"] = f"{user.mention}(`{user.id}`)"
@@ -339,7 +338,7 @@ class Infractions(InfractionScheduler, commands.Cog):
return log_text
- async def _pardon_action(self, infraction: utils.Infraction) -> t.Optional[t.Dict[str, str]]:
+ async def _pardon_action(self, infraction: _utils.Infraction) -> t.Optional[t.Dict[str, str]]:
"""
Execute deactivation steps specific to the infraction's type and return a log dict.
@@ -357,9 +356,9 @@ class Infractions(InfractionScheduler, commands.Cog):
# endregion
# This cannot be static (must have a __func__ attribute).
- def cog_check(self, ctx: Context) -> bool:
+ async def cog_check(self, ctx: Context) -> bool:
"""Only allow moderators to invoke the commands in this cog."""
- return with_role_check(ctx, *constants.MODERATION_ROLES)
+ return await commands.has_any_role(*constants.MODERATION_ROLES).predicate(ctx)
# This cannot be static (must have a __func__ attribute).
async def cog_command_error(self, ctx: Context, error: Exception) -> None:
@@ -368,3 +367,8 @@ class Infractions(InfractionScheduler, commands.Cog):
if discord.User in error.converters or discord.Member in error.converters:
await ctx.send(str(error.errors[0]))
error.handled = True
+
+
+def setup(bot: Bot) -> None:
+ """Load the Infractions cog."""
+ bot.add_cog(Infractions(bot))
diff --git a/bot/cogs/moderation/management.py b/bot/exts/moderation/infraction/management.py
index 672bb0e9c..15ee28537 100644
--- a/bot/cogs/moderation/management.py
+++ b/bot/exts/moderation/infraction/management.py
@@ -10,12 +10,12 @@ from discord.ext.commands import Context
from bot import constants
from bot.bot import Bot
from bot.converters import Expiry, InfractionSearchQuery, allowed_strings, proxy_user
+from bot.exts.moderation.infraction import _utils
+from bot.exts.moderation.infraction.infractions import Infractions
+from bot.exts.moderation.modlog import ModLog
from bot.pagination import LinePaginator
from bot.utils import time
-from bot.utils.checks import in_whitelist_check, with_role_check
-from . import utils
-from .infractions import Infractions
-from .modlog import ModLog
+from bot.utils.checks import in_whitelist_check
log = logging.getLogger(__name__)
@@ -220,7 +220,7 @@ class ModManagement(commands.Cog):
self,
ctx: Context,
embed: discord.Embed,
- infractions: t.Iterable[utils.Infraction]
+ infractions: t.Iterable[_utils.Infraction]
) -> None:
"""Send a paginated embed of infractions for the specified user."""
if not infractions:
@@ -241,7 +241,7 @@ class ModManagement(commands.Cog):
max_size=1000
)
- def infraction_to_string(self, infraction: utils.Infraction) -> str:
+ def infraction_to_string(self, infraction: _utils.Infraction) -> str:
"""Convert the infraction object to a string representation."""
actor_id = infraction["actor"]
guild = self.bot.get_guild(constants.Guild.id)
@@ -282,10 +282,10 @@ class ModManagement(commands.Cog):
# endregion
# This cannot be static (must have a __func__ attribute).
- def cog_check(self, ctx: Context) -> bool:
+ async def cog_check(self, ctx: Context) -> bool:
"""Only allow moderators inside moderator channels to invoke the commands in this cog."""
checks = [
- with_role_check(ctx, *constants.MODERATION_ROLES),
+ await commands.has_any_role(*constants.MODERATION_ROLES).predicate(ctx),
in_whitelist_check(
ctx,
channels=constants.MODERATION_CHANNELS,
@@ -303,3 +303,8 @@ class ModManagement(commands.Cog):
if discord.User in error.converters:
await ctx.send(str(error.errors[0]))
error.handled = True
+
+
+def setup(bot: Bot) -> None:
+ """Load the ModManagement cog."""
+ bot.add_cog(ModManagement(bot))
diff --git a/bot/cogs/moderation/superstarify.py b/bot/exts/moderation/infraction/superstarify.py
index 867de815a..29f41f2ab 100644
--- a/bot/cogs/moderation/superstarify.py
+++ b/bot/exts/moderation/infraction/superstarify.py
@@ -6,15 +6,14 @@ import typing as t
from pathlib import Path
from discord import Colour, Embed, Member
-from discord.ext.commands import Cog, Context, command
+from discord.ext.commands import Cog, Context, command, has_any_role
from bot import constants
from bot.bot import Bot
from bot.converters import Expiry
-from bot.utils.checks import with_role_check
+from bot.exts.moderation.infraction import _utils
+from bot.exts.moderation.infraction._scheduler import InfractionScheduler
from bot.utils.time import format_infraction
-from . import utils
-from .scheduler import InfractionScheduler
log = logging.getLogger(__name__)
NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#nickname-policy"
@@ -67,7 +66,7 @@ class Superstarify(InfractionScheduler, Cog):
reason=f"Superstarified member tried to escape the prison: {infraction['id']}"
)
- notified = await utils.notify_infraction(
+ notified = await _utils.notify_infraction(
user=after,
infr_type="Superstarify",
expires_at=format_infraction(infraction["expires_at"]),
@@ -76,7 +75,7 @@ class Superstarify(InfractionScheduler, Cog):
f"from **{before.display_name}** to **{after.display_name}**, but as you "
"are currently in superstar-prison, you do not have permission to do so."
),
- icon_url=utils.INFRACTION_ICONS["superstar"][0]
+ icon_url=_utils.INFRACTION_ICONS["superstar"][0]
)
if not notified:
@@ -130,12 +129,12 @@ class Superstarify(InfractionScheduler, Cog):
An optional reason can be provided. If no reason is given, the original name will be shown
in a generated reason.
"""
- if await utils.get_active_infraction(ctx, member, "superstar"):
+ if await _utils.get_active_infraction(ctx, member, "superstar"):
return
# Post the infraction to the API
reason = reason or f"old nick: {member.display_name}"
- infraction = await utils.post_infraction(ctx, member, "superstar", reason, duration, active=True)
+ infraction = await _utils.post_infraction(ctx, member, "superstar", reason, duration, active=True)
id_ = infraction["id"]
old_nick = member.display_name
@@ -149,11 +148,11 @@ class Superstarify(InfractionScheduler, Cog):
self.schedule_expiration(infraction)
# Send a DM to the user to notify them of their new infraction.
- await utils.notify_infraction(
+ await _utils.notify_infraction(
user=member,
infr_type="Superstarify",
expires_at=expiry_str,
- icon_url=utils.INFRACTION_ICONS["superstar"][0],
+ icon_url=_utils.INFRACTION_ICONS["superstar"][0],
reason=f"Your nickname didn't comply with our [nickname policy]({NICKNAME_POLICY_URL})."
)
@@ -176,7 +175,7 @@ class Superstarify(InfractionScheduler, Cog):
# Log to the mod log channel.
log.trace(f"Sending apply mod log for superstar #{id_}.")
await self.mod_log.send_log_message(
- icon_url=utils.INFRACTION_ICONS["superstar"][0],
+ icon_url=_utils.INFRACTION_ICONS["superstar"][0],
colour=Colour.gold(),
title="Member achieved superstardom",
thumbnail=member.avatar_url_as(static_format="png"),
@@ -196,7 +195,7 @@ class Superstarify(InfractionScheduler, Cog):
"""Remove the superstarify infraction and allow the user to change their nickname."""
await self.pardon_infraction(ctx, "superstar", member)
- async def _pardon_action(self, infraction: utils.Infraction) -> t.Optional[t.Dict[str, str]]:
+ async def _pardon_action(self, infraction: _utils.Infraction) -> t.Optional[t.Dict[str, str]]:
"""Pardon a superstar infraction and return a log dict."""
if infraction["type"] != "superstar":
return
@@ -213,11 +212,11 @@ class Superstarify(InfractionScheduler, Cog):
return {}
# DM the user about the expiration.
- notified = await utils.notify_pardon(
+ notified = await _utils.notify_pardon(
user=user,
title="You are no longer superstarified",
content="You may now change your nickname on the server.",
- icon_url=utils.INFRACTION_ICONS["superstar"][1]
+ icon_url=_utils.INFRACTION_ICONS["superstar"][1]
)
return {
@@ -234,6 +233,11 @@ class Superstarify(InfractionScheduler, Cog):
return rng.choice(STAR_NAMES)
# This cannot be static (must have a __func__ attribute).
- def cog_check(self, ctx: Context) -> bool:
+ async def cog_check(self, ctx: Context) -> bool:
"""Only allow moderators to invoke the commands in this cog."""
- return with_role_check(ctx, *constants.MODERATION_ROLES)
+ return await has_any_role(*constants.MODERATION_ROLES).predicate(ctx)
+
+
+def setup(bot: Bot) -> None:
+ """Load the Superstarify cog."""
+ bot.add_cog(Superstarify(bot))
diff --git a/bot/cogs/moderation/modlog.py b/bot/exts/moderation/modlog.py
index 5f30d3744..b0d9b5b2b 100644
--- a/bot/cogs/moderation/modlog.py
+++ b/bot/exts/moderation/modlog.py
@@ -834,3 +834,8 @@ class ModLog(Cog, name="ModLog"):
thumbnail=member.avatar_url_as(static_format="png"),
channel_id=Channels.voice_log
)
+
+
+def setup(bot: Bot) -> None:
+ """Load the ModLog cog."""
+ bot.add_cog(ModLog(bot))
diff --git a/bot/cogs/moderation/silence.py b/bot/exts/moderation/silence.py
index f8a6592bc..ac0c1c85e 100644
--- a/bot/cogs/moderation/silence.py
+++ b/bot/exts/moderation/silence.py
@@ -10,7 +10,6 @@ from discord.ext.commands import Context
from bot.bot import Bot
from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles
from bot.converters import HushDurationConverter
-from bot.utils.checks import with_role_check
from bot.utils.scheduling import Scheduler
log = logging.getLogger(__name__)
@@ -160,6 +159,11 @@ class Silence(commands.Cog):
asyncio.create_task(self._mod_alerts_channel.send(message))
# This cannot be static (must have a __func__ attribute).
- def cog_check(self, ctx: Context) -> bool:
+ async def cog_check(self, ctx: Context) -> bool:
"""Only allow moderators to invoke the commands in this cog."""
- return with_role_check(ctx, *MODERATION_ROLES)
+ return await commands.has_any_role(*MODERATION_ROLES).predicate(ctx)
+
+
+def setup(bot: Bot) -> None:
+ """Load the Silence cog."""
+ bot.add_cog(Silence(bot))
diff --git a/bot/cogs/moderation/slowmode.py b/bot/exts/moderation/slowmode.py
index 1d055afac..efd862aa5 100644
--- a/bot/cogs/moderation/slowmode.py
+++ b/bot/exts/moderation/slowmode.py
@@ -4,12 +4,11 @@ from typing import Optional
from dateutil.relativedelta import relativedelta
from discord import TextChannel
-from discord.ext.commands import Cog, Context, group
+from discord.ext.commands import Cog, Context, group, has_any_role
from bot.bot import Bot
from bot.constants import Emojis, MODERATION_ROLES
from bot.converters import DurationDelta
-from bot.decorators import with_role_check
from bot.utils import time
log = logging.getLogger(__name__)
@@ -87,9 +86,9 @@ class Slowmode(Cog):
f'{Emojis.check_mark} The slowmode delay for {channel.mention} has been reset to 0 seconds.'
)
- def cog_check(self, ctx: Context) -> bool:
+ async def cog_check(self, ctx: Context) -> bool:
"""Only allow moderators to invoke the commands in this cog."""
- return with_role_check(ctx, *MODERATION_ROLES)
+ return await has_any_role(*MODERATION_ROLES).predicate(ctx)
def setup(bot: Bot) -> None:
diff --git a/bot/cogs/verification.py b/bot/exts/moderation/verification.py
index 9ae92a228..8ec68ac1e 100644
--- a/bot/cogs/verification.py
+++ b/bot/exts/moderation/verification.py
@@ -6,14 +6,14 @@ from datetime import datetime, timedelta
import discord
from discord.ext import tasks
-from discord.ext.commands import Cog, Context, command, group
+from discord.ext.commands import Cog, Context, command, group, has_any_role
from discord.utils import snowflake_time
from bot import constants
from bot.bot import Bot
-from bot.cogs.moderation import ModLog
-from bot.decorators import in_whitelist, with_role, without_role
-from bot.utils.checks import InWhitelistCheckFailure, without_role_check
+from bot.decorators import has_no_roles, in_whitelist
+from bot.exts.moderation.modlog import ModLog
+from bot.utils.checks import InWhitelistCheckFailure, has_no_roles_check
from bot.utils.redis_cache import RedisCache
log = logging.getLogger(__name__)
@@ -568,7 +568,7 @@ class Verification(Cog):
# endregion
# region: task management commands
- @with_role(*constants.MODERATION_ROLES)
+ @has_any_role(*constants.MODERATION_ROLES)
@group(name="verification")
async def verification_group(self, ctx: Context) -> None:
"""Manage internal verification tasks."""
@@ -653,7 +653,7 @@ class Verification(Cog):
self.bot.stats.incr(f"verification.{category}")
@command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True)
- @without_role(constants.Roles.verified)
+ @has_no_roles(constants.Roles.verified)
@in_whitelist(channels=(constants.Channels.verification,))
async def accept_command(self, ctx: Context, *_) -> None: # We don't actually care about the args
"""Accept our rules and gain access to the rest of the server."""
@@ -736,9 +736,10 @@ class Verification(Cog):
error.handled = True
@staticmethod
- def bot_check(ctx: Context) -> bool:
+ async def bot_check(ctx: Context) -> bool:
"""Block any command within the verification channel that is not !accept."""
- if ctx.channel.id == constants.Channels.verification and without_role_check(ctx, *constants.MODERATION_ROLES):
+ is_verification = ctx.channel.id == constants.Channels.verification
+ if is_verification and await has_no_roles_check(ctx, *constants.MODERATION_ROLES):
return ctx.command.name == "accept"
else:
return True
diff --git a/bot/exts/moderation/watchchannels/__init__.py b/bot/exts/moderation/watchchannels/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/bot/exts/moderation/watchchannels/__init__.py
diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/exts/moderation/watchchannels/_watchchannel.py
index a58b604c0..7118dee02 100644
--- a/bot/cogs/watchchannels/watchchannel.py
+++ b/bot/exts/moderation/watchchannels/_watchchannel.py
@@ -14,10 +14,10 @@ from discord.ext.commands import Cog, Context
from bot.api import ResponseCodeError
from bot.bot import Bot
-from bot.cogs.moderation import ModLog
-from bot.cogs.token_remover import TokenRemover
-from bot.cogs.webhook_remover import WEBHOOK_URL_RE
from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig, Icons
+from bot.exts.filters.token_remover import TokenRemover
+from bot.exts.filters.webhook_remover import WEBHOOK_URL_RE
+from bot.exts.moderation.modlog import ModLog
from bot.pagination import LinePaginator
from bot.utils import CogABCMeta, messages
from bot.utils.time import time_since
diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/exts/moderation/watchchannels/bigbrother.py
index 11ab8917a..3b44056d3 100644
--- a/bot/cogs/watchchannels/bigbrother.py
+++ b/bot/exts/moderation/watchchannels/bigbrother.py
@@ -2,14 +2,13 @@ import logging
import textwrap
from collections import ChainMap
-from discord.ext.commands import Cog, Context, group
+from discord.ext.commands import Cog, Context, group, has_any_role
from bot.bot import Bot
-from bot.cogs.moderation.utils import post_infraction
from bot.constants import Channels, MODERATION_ROLES, Webhooks
from bot.converters import FetchedMember
-from bot.decorators import with_role
-from .watchchannel import WatchChannel
+from bot.exts.moderation.infraction._utils import post_infraction
+from bot.exts.moderation.watchchannels._watchchannel import WatchChannel
log = logging.getLogger(__name__)
@@ -28,13 +27,13 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
)
@group(name='bigbrother', aliases=('bb',), invoke_without_command=True)
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def bigbrother_group(self, ctx: Context) -> None:
"""Monitors users by relaying their messages to the Big Brother watch channel."""
await ctx.send_help(ctx.command)
@bigbrother_group.command(name='watched', aliases=('all', 'list'))
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def watched_command(
self, ctx: Context, oldest_first: bool = False, update_cache: bool = True
) -> None:
@@ -49,7 +48,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache)
@bigbrother_group.command(name='oldest')
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None:
"""
Shows Big Brother monitored users ordered by oldest watched.
@@ -60,7 +59,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache)
@bigbrother_group.command(name='watch', aliases=('w',), root_aliases=('watch',))
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
"""
Relay messages sent by the given `user` to the `#big-brother` channel.
@@ -71,7 +70,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
await self.apply_watch(ctx, user, reason)
@bigbrother_group.command(name='unwatch', aliases=('uw',), root_aliases=('unwatch',))
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
"""Stop relaying messages by the given `user`."""
await self.apply_unwatch(ctx, user, reason)
@@ -163,3 +162,8 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
message = ":x: The specified user is currently not being watched."
await ctx.send(message)
+
+
+def setup(bot: Bot) -> None:
+ """Load the BigBrother cog."""
+ bot.add_cog(BigBrother(bot))
diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/exts/moderation/watchchannels/talentpool.py
index 76d6fe9bd..a77dbe156 100644
--- a/bot/cogs/watchchannels/talentpool.py
+++ b/bot/exts/moderation/watchchannels/talentpool.py
@@ -4,16 +4,15 @@ from collections import ChainMap
from typing import Union
from discord import Color, Embed, Member, User
-from discord.ext.commands import Cog, Context, group
+from discord.ext.commands import Cog, Context, group, has_any_role
from bot.api import ResponseCodeError
from bot.bot import Bot
from bot.constants import Channels, Guild, MODERATION_ROLES, STAFF_ROLES, Webhooks
from bot.converters import FetchedMember
-from bot.decorators import with_role
+from bot.exts.moderation.watchchannels._watchchannel import WatchChannel
from bot.pagination import LinePaginator
from bot.utils import time
-from .watchchannel import WatchChannel
log = logging.getLogger(__name__)
@@ -32,13 +31,13 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
)
@group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True)
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def nomination_group(self, ctx: Context) -> None:
"""Highlights the activity of helper nominees by relaying their messages to the talent pool channel."""
await ctx.send_help(ctx.command)
@nomination_group.command(name='watched', aliases=('all', 'list'), root_aliases=("nominees",))
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def watched_command(
self, ctx: Context, oldest_first: bool = False, update_cache: bool = True
) -> None:
@@ -53,7 +52,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache)
@nomination_group.command(name='oldest')
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None:
"""
Shows talent pool monitored users ordered by oldest nomination.
@@ -64,7 +63,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache)
@nomination_group.command(name='watch', aliases=('w', 'add', 'a'), root_aliases=("nominate",))
- @with_role(*STAFF_ROLES)
+ @has_any_role(*STAFF_ROLES)
async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
"""
Relay messages sent by the given `user` to the `#talent-pool` channel.
@@ -129,7 +128,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
await ctx.send(msg)
@nomination_group.command(name='history', aliases=('info', 'search'))
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def history_command(self, ctx: Context, user: FetchedMember) -> None:
"""Shows the specified user's nomination history."""
result = await self.bot.api_client.get(
@@ -158,7 +157,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
)
@nomination_group.command(name='unwatch', aliases=('end', ), root_aliases=("unnominate",))
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
"""
Ends the active nomination of the specified user with the given reason.
@@ -171,13 +170,13 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
await ctx.send(":x: The specified user does not have an active nomination")
@nomination_group.group(name='edit', aliases=('e',), invoke_without_command=True)
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def nomination_edit_group(self, ctx: Context) -> None:
"""Commands to edit nominations."""
await ctx.send_help(ctx.command)
@nomination_edit_group.command(name='reason')
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def edit_reason_command(self, ctx: Context, nomination_id: int, *, reason: str) -> None:
"""
Edits the reason/unnominate reason for the nomination with the given `id` depending on the status.
@@ -278,3 +277,8 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
)
return lines.strip()
+
+
+def setup(bot: Bot) -> None:
+ """Load the TalentPool cog."""
+ bot.add_cog(TalentPool(bot))
diff --git a/bot/exts/utils/__init__.py b/bot/exts/utils/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/bot/exts/utils/__init__.py
diff --git a/bot/cogs/bot.py b/bot/exts/utils/bot.py
index ddd1cef8d..7ed487d47 100644
--- a/bot/cogs/bot.py
+++ b/bot/exts/utils/bot.py
@@ -5,13 +5,12 @@ import time
from typing import Optional, Tuple
from discord import Embed, Message, RawMessageUpdateEvent, TextChannel
-from discord.ext.commands import Cog, Context, command, group
+from discord.ext.commands import Cog, Context, command, group, has_any_role
from bot.bot import Bot
-from bot.cogs.token_remover import TokenRemover
-from bot.cogs.webhook_remover import WEBHOOK_URL_RE
from bot.constants import Categories, Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs
-from bot.decorators import with_role
+from bot.exts.filters.token_remover import TokenRemover
+from bot.exts.filters.webhook_remover import WEBHOOK_URL_RE
from bot.utils.messages import wait_for_deletion
log = logging.getLogger(__name__)
@@ -39,13 +38,13 @@ class BotCog(Cog, name="Bot"):
self.codeblock_message_ids = {}
@group(invoke_without_command=True, name="bot", hidden=True)
- @with_role(Roles.verified)
+ @has_any_role(Roles.verified)
async def botinfo_group(self, ctx: Context) -> None:
"""Bot informational commands."""
await ctx.send_help(ctx.command)
@botinfo_group.command(name='about', aliases=('info',), hidden=True)
- @with_role(Roles.verified)
+ @has_any_role(Roles.verified)
async def about_command(self, ctx: Context) -> None:
"""Get information about the bot."""
embed = Embed(
@@ -63,7 +62,7 @@ class BotCog(Cog, name="Bot"):
await ctx.send(embed=embed)
@command(name='echo', aliases=('print',))
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def echo_command(self, ctx: Context, channel: Optional[TextChannel], *, text: str) -> None:
"""Repeat the given message in either a specified channel or the current channel."""
if channel is None:
@@ -72,7 +71,7 @@ class BotCog(Cog, name="Bot"):
await channel.send(text)
@command(name='embed')
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def embed_command(self, ctx: Context, channel: Optional[TextChannel], *, text: str) -> None:
"""Send the input within an embed to either a specified channel or the current channel."""
embed = Embed(description=text)
diff --git a/bot/cogs/clean.py b/bot/exts/utils/clean.py
index f436e531a..236603dba 100644
--- a/bot/cogs/clean.py
+++ b/bot/exts/utils/clean.py
@@ -5,14 +5,13 @@ from typing import Iterable, Optional
from discord import Colour, Embed, Message, TextChannel, User
from discord.ext import commands
-from discord.ext.commands import Cog, Context, group
+from discord.ext.commands import Cog, Context, group, has_any_role
from bot.bot import Bot
-from bot.cogs.moderation import ModLog
from bot.constants import (
Channels, CleanMessages, Colours, Event, Icons, MODERATION_ROLES, NEGATIVE_REPLIES
)
-from bot.decorators import with_role
+from bot.exts.moderation.modlog import ModLog
log = logging.getLogger(__name__)
@@ -192,13 +191,13 @@ class Clean(Cog):
)
@group(invoke_without_command=True, name="clean", aliases=["purge"])
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def clean_group(self, ctx: Context) -> None:
"""Commands for cleaning messages in channels."""
await ctx.send_help(ctx.command)
@clean_group.command(name="user", aliases=["users"])
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def clean_user(
self,
ctx: Context,
@@ -210,7 +209,7 @@ class Clean(Cog):
await self._clean_messages(amount, ctx, user=user, channels=channels)
@clean_group.command(name="all", aliases=["everything"])
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def clean_all(
self,
ctx: Context,
@@ -221,7 +220,7 @@ class Clean(Cog):
await self._clean_messages(amount, ctx, channels=channels)
@clean_group.command(name="bots", aliases=["bot"])
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def clean_bots(
self,
ctx: Context,
@@ -232,7 +231,7 @@ class Clean(Cog):
await self._clean_messages(amount, ctx, bots_only=True, channels=channels)
@clean_group.command(name="regex", aliases=["word", "expression"])
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def clean_regex(
self,
ctx: Context,
@@ -244,7 +243,7 @@ class Clean(Cog):
await self._clean_messages(amount, ctx, regex=regex, channels=channels)
@clean_group.command(name="message", aliases=["messages"])
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def clean_message(self, ctx: Context, message: Message) -> None:
"""Delete all messages until certain message, stop cleaning after hitting the `message`."""
await self._clean_messages(
@@ -255,7 +254,7 @@ class Clean(Cog):
)
@clean_group.command(name="stop", aliases=["cancel", "abort"])
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def clean_cancel(self, ctx: Context) -> None:
"""If there is an ongoing cleaning process, attempt to immediately cancel it."""
self.cleaning = False
diff --git a/bot/cogs/eval.py b/bot/exts/utils/eval.py
index 23e5998d8..6419b320e 100644
--- a/bot/cogs/eval.py
+++ b/bot/exts/utils/eval.py
@@ -9,11 +9,10 @@ from io import StringIO
from typing import Any, Optional, Tuple
import discord
-from discord.ext.commands import Cog, Context, group
+from discord.ext.commands import Cog, Context, group, has_any_role
from bot.bot import Bot
from bot.constants import Roles
-from bot.decorators import with_role
from bot.interpreter import Interpreter
from bot.utils import find_nth_occurrence, send_to_paste_service
@@ -199,14 +198,14 @@ async def func(): # (None,) -> Any
await ctx.send(f"```py\n{out}```", embed=embed)
@group(name='internal', aliases=('int',))
- @with_role(Roles.owners, Roles.admins)
+ @has_any_role(Roles.owners, Roles.admins)
async def internal_group(self, ctx: Context) -> None:
"""Internal commands. Top secret!"""
if not ctx.invoked_subcommand:
await ctx.send_help(ctx.command)
@internal_group.command(name='eval', aliases=('e',))
- @with_role(Roles.admins, Roles.owners)
+ @has_any_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/exts/utils/extensions.py
index 396e406b0..418db0150 100644
--- a/bot/cogs/extensions.py
+++ b/bot/exts/utils/extensions.py
@@ -2,25 +2,22 @@ import functools
import logging
import typing as t
from enum import Enum
-from pkgutil import iter_modules
from discord import Colour, Embed
from discord.ext import commands
from discord.ext.commands import Context, group
+from bot import exts
from bot.bot import Bot
from bot.constants import Emojis, MODERATION_ROLES, Roles, URLs
from bot.pagination import LinePaginator
-from bot.utils.checks import with_role_check
+from bot.utils.extensions import EXTENSIONS, unqualify
log = logging.getLogger(__name__)
-UNLOAD_BLACKLIST = {"bot.cogs.extensions", "bot.cogs.modlog"}
-EXTENSIONS = frozenset(
- ext.name
- for ext in iter_modules(("bot/cogs",), "bot.cogs.")
- if ext.name[-1] != "_"
-)
+
+UNLOAD_BLACKLIST = {f"{exts.__name__}.utils.extensions", f"{exts.__name__}.moderation.modlog"}
+BASE_PATH_LEN = len(exts.__name__.split("."))
class Action(Enum):
@@ -47,11 +44,25 @@ class Extension(commands.Converter):
argument = argument.lower()
- if "." not in argument:
- argument = f"bot.cogs.{argument}"
-
if argument in EXTENSIONS:
return argument
+ elif (qualified_arg := f"{exts.__name__}.{argument}") in EXTENSIONS:
+ return qualified_arg
+
+ matches = []
+ for ext in EXTENSIONS:
+ if argument == unqualify(ext):
+ matches.append(ext)
+
+ if len(matches) > 1:
+ matches.sort()
+ names = "\n".join(matches)
+ raise commands.BadArgument(
+ f":x: `{argument}` is an ambiguous extension name. "
+ f"Please use one of the following fully-qualified names.```\n{names}```"
+ )
+ elif matches:
+ return matches[0]
else:
raise commands.BadArgument(f":x: Could not find the extension `{argument}`.")
@@ -139,27 +150,44 @@ class Extensions(commands.Cog):
Grey indicates that the extension is unloaded.
Green indicates that the extension is currently loaded.
"""
- embed = Embed()
- lines = []
-
- embed.colour = Colour.blurple()
+ embed = Embed(colour=Colour.blurple())
embed.set_author(
name="Extensions List",
url=URLs.github_bot_repo,
icon_url=URLs.bot_avatar
)
- for ext in sorted(list(EXTENSIONS)):
+ lines = []
+ categories = self.group_extension_statuses()
+ for category, extensions in sorted(categories.items()):
+ # Treat each category as a single line by concatenating everything.
+ # This ensures the paginator will not cut off a page in the middle of a category.
+ category = category.replace("_", " ").title()
+ extensions = "\n".join(sorted(extensions))
+ lines.append(f"**{category}**\n{extensions}\n")
+
+ log.debug(f"{ctx.author} requested a list of all cogs. Returning a paginated list.")
+ await LinePaginator.paginate(lines, ctx, embed, scale_to_size=700, empty=False)
+
+ def group_extension_statuses(self) -> t.Mapping[str, str]:
+ """Return a mapping of extension names and statuses to their categories."""
+ categories = {}
+
+ for ext in EXTENSIONS:
if ext in self.bot.extensions:
status = Emojis.status_online
else:
status = Emojis.status_offline
- ext = ext.rsplit(".", 1)[1]
- lines.append(f"{status} {ext}")
+ path = ext.split(".")
+ if len(path) > BASE_PATH_LEN + 1:
+ category = " - ".join(path[BASE_PATH_LEN:-1])
+ else:
+ category = "uncategorised"
- log.debug(f"{ctx.author} requested a list of all cogs. Returning a paginated list.")
- await LinePaginator.paginate(lines, ctx, embed, max_size=300, empty=False)
+ categories.setdefault(category, []).append(f"{status} {path[-1]}")
+
+ return categories
def batch_manage(self, action: Action, *extensions: str) -> str:
"""
@@ -219,9 +247,9 @@ class Extensions(commands.Cog):
return msg, error_msg
# This cannot be static (must have a __func__ attribute).
- def cog_check(self, ctx: Context) -> bool:
+ async 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_developers)
+ return await commands.has_any_role(*MODERATION_ROLES, Roles.core_developers).predicate(ctx)
# 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/jams.py b/bot/exts/utils/jams.py
index b3102db2f..1c0988343 100644
--- a/bot/cogs/jams.py
+++ b/bot/exts/utils/jams.py
@@ -7,7 +7,6 @@ from more_itertools import unique_everseen
from bot.bot import Bot
from bot.constants import Roles
-from bot.decorators import with_role
log = logging.getLogger(__name__)
@@ -22,7 +21,7 @@ class CodeJams(commands.Cog):
self.bot = bot
@commands.command()
- @with_role(Roles.admins)
+ @commands.has_any_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.
diff --git a/bot/cogs/reminders.py b/bot/exts/utils/reminders.py
index 08bce2153..6806f2889 100644
--- a/bot/cogs/reminders.py
+++ b/bot/exts/utils/reminders.py
@@ -15,7 +15,7 @@ from bot.bot import Bot
from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Roles, STAFF_ROLES
from bot.converters import Duration
from bot.pagination import LinePaginator
-from bot.utils.checks import with_role_check, without_role_check
+from bot.utils.checks import has_any_role_check, has_no_roles_check
from bot.utils.messages import send_denial
from bot.utils.scheduling import Scheduler
from bot.utils.time import humanize_delta
@@ -117,9 +117,9 @@ class Reminders(Cog):
If mentions aren't allowed, also return the type of mention(s) disallowed.
"""
- if without_role_check(ctx, *STAFF_ROLES):
+ if await has_no_roles_check(ctx, *STAFF_ROLES):
return False, "members/roles"
- elif without_role_check(ctx, *MODERATION_ROLES):
+ elif await has_no_roles_check(ctx, *MODERATION_ROLES):
return all(isinstance(mention, discord.Member) for mention in mentions), "roles"
else:
return True, ""
@@ -240,7 +240,7 @@ class Reminders(Cog):
Expiration is parsed per: http://strftime.org/
"""
# If the user is not staff, we need to verify whether or not to make a reminder at all.
- if without_role_check(ctx, *STAFF_ROLES):
+ if await has_no_roles_check(ctx, *STAFF_ROLES):
# If they don't have permission to set a reminder in this channel
if ctx.channel.id not in WHITELISTED_CHANNELS:
@@ -431,7 +431,7 @@ class Reminders(Cog):
The check passes when the user is an admin, or if they created the reminder.
"""
- if with_role_check(ctx, Roles.admins):
+ if await has_any_role_check(ctx, Roles.admins):
return True
api_response = await self.bot.api_client.get(f"bot/reminders/{reminder_id}")
diff --git a/bot/cogs/snekbox.py b/bot/exts/utils/snekbox.py
index 03bf454ac..b3baffba2 100644
--- a/bot/cogs/snekbox.py
+++ b/bot/exts/utils/snekbox.py
@@ -150,6 +150,7 @@ class Snekbox(Cog):
output = output.replace("<!@", "<!@\u200B") # Zero-width space
if ESCAPE_REGEX.findall(output):
+ paste_link = await self.upload_output(original_output)
return "Code block escape attempt detected; will not output result", paste_link
truncated = False
diff --git a/bot/cogs/utils.py b/bot/exts/utils/utils.py
index d96abbd5a..6b6941064 100644
--- a/bot/cogs/utils.py
+++ b/bot/exts/utils/utils.py
@@ -7,11 +7,11 @@ from io import StringIO
from typing import Tuple, Union
from discord import Colour, Embed, utils
-from discord.ext.commands import BadArgument, Cog, Context, clean_content, command
+from discord.ext.commands import BadArgument, Cog, Context, clean_content, command, has_any_role
from bot.bot import Bot
from bot.constants import Channels, MODERATION_ROLES, STAFF_ROLES
-from bot.decorators import in_whitelist, with_role
+from bot.decorators import in_whitelist
from bot.pagination import LinePaginator
from bot.utils import messages
@@ -224,7 +224,7 @@ class Utils(Cog):
await ctx.send(embed=embed)
@command(aliases=("poll",))
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def vote(self, ctx: Context, title: clean_content(fix_channel_mentions=True), *options: str) -> None:
"""
Build a quick voting poll with matching reactions with the provided options.
diff --git a/bot/rules/__init__.py b/bot/rules/__init__.py
index 8a69cadee..a01ceae73 100644
--- a/bot/rules/__init__.py
+++ b/bot/rules/__init__.py
@@ -10,4 +10,3 @@ from .links import apply as apply_links
from .mentions import apply as apply_mentions
from .newlines import apply as apply_newlines
from .role_mentions import apply as apply_role_mentions
-from .everyone_ping import apply as apply_everyone_ping
diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py
deleted file mode 100644
index 89d9fe570..000000000
--- a/bot/rules/everyone_ping.py
+++ /dev/null
@@ -1,41 +0,0 @@
-import random
-import re
-from typing import Dict, Iterable, List, Optional, Tuple
-
-from discord import Embed, Member, Message
-
-from bot.constants import Colours, Guild, NEGATIVE_REPLIES
-
-# Generate regex for checking for pings:
-guild_id = Guild.id
-EVERYONE_RE_INLINE_CODE = re.compile(rf"^(?!`).*@everyone.*(?!`)$|^(?!`).*<@&{guild_id}>.*(?!`)$")
-EVERYONE_RE_MULTILINE_CODE = re.compile(rf"^(?!```).*@everyone.*(?!```)$|^(?!```).*<@&{guild_id}>.*(?!```)$")
-
-
-async def apply(
- last_message: Message,
- recent_messages: List[Message],
- config: Dict[str, int],
-) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]:
- """Detects if a user has sent an '@everyone' ping."""
- relevant_messages = tuple(msg for msg in recent_messages if msg.author == last_message.author)
-
- everyone_messages_count = 0
- for msg in relevant_messages:
- num_everyone_pings_inline = len(re.findall(EVERYONE_RE_INLINE_CODE, msg.content))
- num_everyone_pings_multiline = len(re.findall(EVERYONE_RE_MULTILINE_CODE, msg.content))
- if num_everyone_pings_inline and num_everyone_pings_multiline:
- everyone_messages_count += 1
-
- if everyone_messages_count > config["max"]:
- # Send the channel an embed giving the user more info:
- embed_text = f"Please don't try to ping {last_message.guild.member_count:,} people."
- embed = Embed(title=random.choice(NEGATIVE_REPLIES), description=embed_text, colour=Colours.soft_red)
- await last_message.channel.send(embed=embed)
-
- return (
- "pinged the everyone role",
- (last_message.author,),
- relevant_messages,
- )
- return None
diff --git a/bot/utils/checks.py b/bot/utils/checks.py
index f0ef36302..460a937d8 100644
--- a/bot/utils/checks.py
+++ b/bot/utils/checks.py
@@ -1,6 +1,6 @@
import datetime
import logging
-from typing import Callable, Container, Iterable, Optional
+from typing import Callable, Container, Iterable, Optional, Union
from discord.ext.commands import (
BucketType,
@@ -11,6 +11,8 @@ from discord.ext.commands import (
Context,
Cooldown,
CooldownMapping,
+ NoPrivateMessage,
+ has_any_role,
)
from bot import constants
@@ -89,35 +91,32 @@ def in_whitelist_check(
return False
-def with_role_check(ctx: Context, *role_ids: int) -> bool:
- """Returns True if the user has any one of the roles in role_ids."""
- if not ctx.guild: # Return False in a DM
- log.trace(f"{ctx.author} tried to use the '{ctx.command.name}'command from a DM. "
- "This command is restricted by the with_role decorator. Rejecting request.")
- return False
+async def has_any_role_check(ctx: Context, *roles: Union[str, int]) -> bool:
+ """
+ Returns True if the context's author has any of the specified roles.
- for role in ctx.author.roles:
- if role.id in role_ids:
- log.trace(f"{ctx.author} has the '{role.name}' role, and passes the check.")
- return True
+ `roles` are the names or IDs of the roles for which to check.
+ False is always returns if the context is outside a guild.
+ """
+ try:
+ return await has_any_role(*roles).predicate(ctx)
+ except CheckFailure:
+ return False
- log.trace(f"{ctx.author} does not have the required role to use "
- f"the '{ctx.command.name}' command, so the request is rejected.")
- return False
+async def has_no_roles_check(ctx: Context, *roles: Union[str, int]) -> bool:
+ """
+ Returns True if the context's author doesn't have any of the specified roles.
-def without_role_check(ctx: Context, *role_ids: int) -> bool:
- """Returns True if the user does not have any of the roles in role_ids."""
- if not ctx.guild: # Return False in a DM
- log.trace(f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM. "
- "This command is restricted by the without_role decorator. Rejecting request.")
+ `roles` are the names or IDs of the roles for which to check.
+ False is always returns if the context is outside a guild.
+ """
+ try:
+ return not await has_any_role(*roles).predicate(ctx)
+ except NoPrivateMessage:
return False
-
- author_roles = [role.id for role in ctx.author.roles]
- check = all(role not in author_roles for role in role_ids)
- log.trace(f"{ctx.author} tried to call the '{ctx.command.name}' command. "
- f"The result of the without_role check was {check}.")
- return check
+ except CheckFailure:
+ return True
def cooldown_with_role_bypass(rate: int, per: float, type: BucketType = BucketType.default, *,
diff --git a/bot/utils/extensions.py b/bot/utils/extensions.py
new file mode 100644
index 000000000..50350ea8d
--- /dev/null
+++ b/bot/utils/extensions.py
@@ -0,0 +1,34 @@
+import importlib
+import inspect
+import pkgutil
+from typing import Iterator, NoReturn
+
+from bot import exts
+
+
+def unqualify(name: str) -> str:
+ """Return an unqualified name given a qualified module/package `name`."""
+ return name.rsplit(".", maxsplit=1)[-1]
+
+
+def walk_extensions() -> Iterator[str]:
+ """Yield extension names from the bot.exts subpackage."""
+
+ def on_error(name: str) -> NoReturn:
+ raise ImportError(name=name) # pragma: no cover
+
+ for module in pkgutil.walk_packages(exts.__path__, f"{exts.__name__}.", onerror=on_error):
+ if unqualify(module.name).startswith("_"):
+ # Ignore module/package names starting with an underscore.
+ continue
+
+ if module.ispkg:
+ imported = importlib.import_module(module.name)
+ if not inspect.isfunction(getattr(imported, "setup", None)):
+ # If it lacks a setup function, it's not an extension.
+ continue
+
+ yield module.name
+
+
+EXTENSIONS = frozenset(walk_extensions())
diff --git a/config-default.yml b/config-default.yml
index fe15e5a87..c809a7340 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -271,17 +271,19 @@ guild:
filter:
# What do we filter?
- filter_zalgo: false
- filter_invites: true
- filter_domains: true
- watch_regex: true
- watch_rich_embeds: true
+ filter_zalgo: false
+ filter_invites: true
+ filter_domains: true
+ filter_everyone_ping: true
+ watch_regex: true
+ watch_rich_embeds: true
# Notify user on filter?
# Notifications are not expected for "watchlist" type filters
- notify_user_zalgo: false
- notify_user_invites: true
- notify_user_domains: false
+ notify_user_zalgo: false
+ notify_user_invites: true
+ notify_user_domains: false
+ notify_user_everyone_ping: true
# Filter configuration
ping_everyone: true
@@ -387,12 +389,6 @@ anti_spam:
interval: 10
max: 3
- # The everyone ping filter is temporarily disabled
- # until we've fixed a couple of bugs.
- # everyone_ping:
- # interval: 10
- # max: 0
-
reddit:
subreddits:
diff --git a/tests/bot/exts/__init__.py b/tests/bot/exts/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/tests/bot/exts/__init__.py
diff --git a/tests/bot/exts/backend/__init__.py b/tests/bot/exts/backend/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/tests/bot/exts/backend/__init__.py
diff --git a/tests/bot/exts/backend/sync/__init__.py b/tests/bot/exts/backend/sync/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/tests/bot/exts/backend/sync/__init__.py
diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/exts/backend/sync/test_base.py
index 70aea2bab..886c243cf 100644
--- a/tests/bot/cogs/sync/test_base.py
+++ b/tests/bot/exts/backend/sync/test_base.py
@@ -6,7 +6,7 @@ import discord
from bot import constants
from bot.api import ResponseCodeError
-from bot.cogs.sync.syncers import Syncer, _Diff
+from bot.exts.backend.sync._syncers import Syncer, _Diff
from tests import helpers
diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/exts/backend/sync/test_cog.py
index 120bc991d..1b89564f2 100644
--- a/tests/bot/cogs/sync/test_cog.py
+++ b/tests/bot/exts/backend/sync/test_cog.py
@@ -5,8 +5,9 @@ import discord
from bot import constants
from bot.api import ResponseCodeError
-from bot.cogs import sync
-from bot.cogs.sync.syncers import Syncer
+from bot.exts.backend import sync
+from bot.exts.backend.sync._cog import Sync
+from bot.exts.backend.sync._syncers import Syncer
from tests import helpers
from tests.base import CommandTestCase
@@ -29,19 +30,19 @@ class SyncCogTestCase(unittest.IsolatedAsyncioTestCase):
self.bot = helpers.MockBot()
self.role_syncer_patcher = mock.patch(
- "bot.cogs.sync.syncers.RoleSyncer",
+ "bot.exts.backend.sync._syncers.RoleSyncer",
autospec=Syncer,
spec_set=True
)
self.user_syncer_patcher = mock.patch(
- "bot.cogs.sync.syncers.UserSyncer",
+ "bot.exts.backend.sync._syncers.UserSyncer",
autospec=Syncer,
spec_set=True
)
self.RoleSyncer = self.role_syncer_patcher.start()
self.UserSyncer = self.user_syncer_patcher.start()
- self.cog = sync.Sync(self.bot)
+ self.cog = Sync(self.bot)
def tearDown(self):
self.role_syncer_patcher.stop()
@@ -59,7 +60,7 @@ class SyncCogTestCase(unittest.IsolatedAsyncioTestCase):
class SyncCogTests(SyncCogTestCase):
"""Tests for the Sync cog."""
- @mock.patch.object(sync.Sync, "sync_guild", new_callable=mock.MagicMock)
+ @mock.patch.object(Sync, "sync_guild", new_callable=mock.MagicMock)
def test_sync_cog_init(self, sync_guild):
"""Should instantiate syncers and run a sync for the guild."""
# Reset because a Sync cog was already instantiated in setUp.
@@ -70,7 +71,7 @@ class SyncCogTests(SyncCogTestCase):
mock_sync_guild_coro = mock.MagicMock()
sync_guild.return_value = mock_sync_guild_coro
- sync.Sync(self.bot)
+ Sync(self.bot)
self.RoleSyncer.assert_called_once_with(self.bot)
self.UserSyncer.assert_called_once_with(self.bot)
@@ -131,7 +132,7 @@ class SyncCogListenerTests(SyncCogTestCase):
super().setUp()
self.cog.patch_user = mock.AsyncMock(spec_set=self.cog.patch_user)
- self.guild_id_patcher = mock.patch("bot.cogs.sync.cog.constants.Guild.id", 5)
+ self.guild_id_patcher = mock.patch("bot.exts.backend.sync._cog.constants.Guild.id", 5)
self.guild_id = self.guild_id_patcher.start()
self.guild = helpers.MockGuild(id=self.guild_id)
diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/exts/backend/sync/test_roles.py
index 79eee98f4..7b9f40cad 100644
--- a/tests/bot/cogs/sync/test_roles.py
+++ b/tests/bot/exts/backend/sync/test_roles.py
@@ -3,7 +3,7 @@ from unittest import mock
import discord
-from bot.cogs.sync.syncers import RoleSyncer, _Diff, _Role
+from bot.exts.backend.sync._syncers import RoleSyncer, _Diff, _Role
from tests import helpers
diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/exts/backend/sync/test_users.py
index 002a947ad..c0a1da35c 100644
--- a/tests/bot/cogs/sync/test_users.py
+++ b/tests/bot/exts/backend/sync/test_users.py
@@ -1,7 +1,7 @@
import unittest
from unittest import mock
-from bot.cogs.sync.syncers import UserSyncer, _Diff, _User
+from bot.exts.backend.sync._syncers import UserSyncer, _Diff, _User
from tests import helpers
diff --git a/tests/bot/cogs/test_logging.py b/tests/bot/exts/backend/test_logging.py
index 8a18fdcd6..466f207d9 100644
--- a/tests/bot/cogs/test_logging.py
+++ b/tests/bot/exts/backend/test_logging.py
@@ -2,7 +2,7 @@ import unittest
from unittest.mock import patch
from bot import constants
-from bot.cogs.logging import Logging
+from bot.exts.backend.logging import Logging
from tests.helpers import MockBot, MockTextChannel
@@ -14,7 +14,7 @@ class LoggingTests(unittest.IsolatedAsyncioTestCase):
self.cog = Logging(self.bot)
self.dev_log = MockTextChannel(id=1234, name="dev-log")
- @patch("bot.cogs.logging.DEBUG_MODE", False)
+ @patch("bot.exts.backend.logging.DEBUG_MODE", False)
async def test_debug_mode_false(self):
"""Should send connected message to dev-log."""
self.bot.get_channel.return_value = self.dev_log
@@ -24,7 +24,7 @@ class LoggingTests(unittest.IsolatedAsyncioTestCase):
self.bot.get_channel.assert_called_once_with(constants.Channels.dev_log)
self.dev_log.send.assert_awaited_once()
- @patch("bot.cogs.logging.DEBUG_MODE", True)
+ @patch("bot.exts.backend.logging.DEBUG_MODE", True)
async def test_debug_mode_true(self):
"""Should not send anything to dev-log."""
await self.cog.startup_greeting()
diff --git a/tests/bot/exts/filters/__init__.py b/tests/bot/exts/filters/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/tests/bot/exts/filters/__init__.py
diff --git a/tests/bot/cogs/test_antimalware.py b/tests/bot/exts/filters/test_antimalware.py
index f50c0492d..3393c6cdc 100644
--- a/tests/bot/cogs/test_antimalware.py
+++ b/tests/bot/exts/filters/test_antimalware.py
@@ -3,8 +3,8 @@ from unittest.mock import AsyncMock, Mock
from discord import NotFound
-from bot.cogs import antimalware
from bot.constants import Channels, STAFF_ROLES
+from bot.exts.filters import antimalware
from tests.helpers import MockAttachment, MockBot, MockMessage, MockRole
diff --git a/tests/bot/cogs/test_antispam.py b/tests/bot/exts/filters/test_antispam.py
index ce5472c71..6a0e4fded 100644
--- a/tests/bot/cogs/test_antispam.py
+++ b/tests/bot/exts/filters/test_antispam.py
@@ -1,6 +1,6 @@
import unittest
-from bot.cogs import antispam
+from bot.exts.filters import antispam
class AntispamConfigurationValidationTests(unittest.TestCase):
diff --git a/tests/bot/cogs/test_security.py b/tests/bot/exts/filters/test_security.py
index 9d1a62f7e..c0c3baa42 100644
--- a/tests/bot/cogs/test_security.py
+++ b/tests/bot/exts/filters/test_security.py
@@ -3,7 +3,7 @@ from unittest.mock import MagicMock
from discord.ext.commands import NoPrivateMessage
-from bot.cogs import security
+from bot.exts.filters import security
from tests.helpers import MockBot, MockContext
diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/exts/filters/test_token_remover.py
index 3349caa73..a0ff8a877 100644
--- a/tests/bot/cogs/test_token_remover.py
+++ b/tests/bot/exts/filters/test_token_remover.py
@@ -6,9 +6,9 @@ from unittest.mock import MagicMock
from discord import Colour, NotFound
from bot import constants
-from bot.cogs import token_remover
-from bot.cogs.moderation import ModLog
-from bot.cogs.token_remover import Token, TokenRemover
+from bot.exts.filters import token_remover
+from bot.exts.filters.token_remover import Token, TokenRemover
+from bot.exts.moderation.modlog import ModLog
from tests.helpers import MockBot, MockMessage, autospec
@@ -132,7 +132,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
await cog.on_message(msg)
find_token_in_message.assert_not_called()
- @autospec("bot.cogs.token_remover", "TOKEN_RE")
+ @autospec("bot.exts.filters.token_remover", "TOKEN_RE")
def test_find_token_no_matches(self, token_re):
"""None should be returned if the regex matches no tokens in a message."""
token_re.finditer.return_value = ()
@@ -143,8 +143,8 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
token_re.finditer.assert_called_once_with(self.msg.content)
@autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp")
- @autospec("bot.cogs.token_remover", "Token")
- @autospec("bot.cogs.token_remover", "TOKEN_RE")
+ @autospec("bot.exts.filters.token_remover", "Token")
+ @autospec("bot.exts.filters.token_remover", "TOKEN_RE")
def test_find_token_valid_match(self, token_re, token_cls, is_valid_id, is_valid_timestamp):
"""The first match with a valid user ID and timestamp should be returned as a `Token`."""
matches = [
@@ -167,8 +167,8 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
token_re.finditer.assert_called_once_with(self.msg.content)
@autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp")
- @autospec("bot.cogs.token_remover", "Token")
- @autospec("bot.cogs.token_remover", "TOKEN_RE")
+ @autospec("bot.exts.filters.token_remover", "Token")
+ @autospec("bot.exts.filters.token_remover", "TOKEN_RE")
def test_find_token_invalid_matches(self, token_re, token_cls, is_valid_id, is_valid_timestamp):
"""None should be returned if no matches have valid user IDs or timestamps."""
token_re.finditer.return_value = [mock.create_autospec(Match, spec_set=True, instance=True)]
@@ -230,7 +230,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
results = [match[0] for match in results]
self.assertCountEqual((token_1, token_2), results)
- @autospec("bot.cogs.token_remover", "LOG_MESSAGE")
+ @autospec("bot.exts.filters.token_remover", "LOG_MESSAGE")
def test_format_log_message(self, log_message):
"""Should correctly format the log message with info from the message and token."""
token = Token("NDY3MjIzMjMwNjUwNzc3NjQx", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4")
@@ -249,7 +249,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
)
@mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock)
- @autospec("bot.cogs.token_remover", "log")
+ @autospec("bot.exts.filters.token_remover", "log")
@autospec(TokenRemover, "format_log_message")
async def test_take_action(self, format_log_message, logger, mod_log_property):
"""Should delete the message and send a mod log."""
@@ -299,7 +299,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
class TokenRemoverExtensionTests(unittest.TestCase):
"""Tests for the token_remover extension."""
- @autospec("bot.cogs.token_remover", "TokenRemover")
+ @autospec("bot.exts.filters.token_remover", "TokenRemover")
def test_extension_setup(self, cog):
"""The TokenRemover cog should be added."""
bot = MockBot()
diff --git a/tests/bot/exts/info/__init__.py b/tests/bot/exts/info/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/tests/bot/exts/info/__init__.py
diff --git a/tests/bot/cogs/test_information.py b/tests/bot/exts/info/test_information.py
index 77b0ddf17..ba8d5d608 100644
--- a/tests/bot/cogs/test_information.py
+++ b/tests/bot/exts/info/test_information.py
@@ -6,11 +6,11 @@ import unittest.mock
import discord
from bot import constants
-from bot.cogs import information
+from bot.exts.info import information
from bot.utils.checks import InWhitelistCheckFailure
from tests import helpers
-COG_PATH = "bot.cogs.information.Information"
+COG_PATH = "bot.exts.info.information.Information"
class InformationCogTests(unittest.TestCase):
@@ -97,7 +97,7 @@ class InformationCogTests(unittest.TestCase):
self.assertEqual(admin_embed.title, "Admins info")
self.assertEqual(admin_embed.colour, discord.Colour.red())
- @unittest.mock.patch('bot.cogs.information.time_since')
+ @unittest.mock.patch('bot.exts.info.information.time_since')
def test_server_info_command(self, time_since_patch):
time_since_patch.return_value = '2 days ago'
@@ -339,8 +339,8 @@ class UserInfractionHelperMethodTests(unittest.TestCase):
self._method_subtests(self.cog.user_nomination_counts, test_values, header)
[email protected]("bot.cogs.information.time_since", new=unittest.mock.MagicMock(return_value="1 year ago"))
[email protected]("bot.cogs.information.constants.MODERATION_CHANNELS", new=[50])
[email protected]("bot.exts.info.information.time_since", new=unittest.mock.MagicMock(return_value="1 year ago"))
[email protected]("bot.exts.info.information.constants.MODERATION_CHANNELS", new=[50])
class UserEmbedTests(unittest.TestCase):
"""Tests for the creation of the `!user` embed."""
@@ -515,7 +515,7 @@ class UserEmbedTests(unittest.TestCase):
self.assertEqual(embed.thumbnail.url, "avatar url")
[email protected]("bot.cogs.information.constants")
[email protected]("bot.exts.info.information.constants")
class UserCommandTests(unittest.TestCase):
"""Tests for the `!user` command."""
@@ -554,7 +554,7 @@ class UserCommandTests(unittest.TestCase):
with self.assertRaises(InWhitelistCheckFailure, msg=msg):
asyncio.run(self.cog.user_info.callback(self.cog, ctx))
- @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock)
+ @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed")
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]
@@ -567,7 +567,7 @@ class UserCommandTests(unittest.TestCase):
create_embed.assert_called_once_with(ctx, self.author)
ctx.send.assert_called_once()
- @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock)
+ @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed")
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]
@@ -580,7 +580,7 @@ class UserCommandTests(unittest.TestCase):
create_embed.assert_called_once_with(ctx, self.author)
ctx.send.assert_called_once()
- @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock)
+ @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed")
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]
@@ -593,7 +593,7 @@ class UserCommandTests(unittest.TestCase):
create_embed.assert_called_once_with(ctx, self.moderator)
ctx.send.assert_called_once()
- @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock)
+ @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed")
def test_moderators_can_target_another_member(self, create_embed, constants):
"""A moderator should be able to use `!user` targeting another user."""
constants.MODERATION_ROLES = [self.moderator_role.id]
diff --git a/tests/bot/exts/moderation/__init__.py b/tests/bot/exts/moderation/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/tests/bot/exts/moderation/__init__.py
diff --git a/tests/bot/exts/moderation/infraction/__init__.py b/tests/bot/exts/moderation/infraction/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/tests/bot/exts/moderation/infraction/__init__.py
diff --git a/tests/bot/cogs/moderation/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py
index da4e92ccc..be1b649e1 100644
--- a/tests/bot/cogs/moderation/test_infractions.py
+++ b/tests/bot/exts/moderation/infraction/test_infractions.py
@@ -2,7 +2,7 @@ import textwrap
import unittest
from unittest.mock import AsyncMock, Mock, patch
-from bot.cogs.moderation.infractions import Infractions
+from bot.exts.moderation.infraction.infractions import Infractions
from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole
@@ -17,8 +17,8 @@ class TruncationTests(unittest.IsolatedAsyncioTestCase):
self.guild = MockGuild(id=4567)
self.ctx = MockContext(bot=self.bot, author=self.user, guild=self.guild)
- @patch("bot.cogs.moderation.utils.get_active_infraction")
- @patch("bot.cogs.moderation.utils.post_infraction")
+ @patch("bot.exts.moderation.infraction._utils.get_active_infraction")
+ @patch("bot.exts.moderation.infraction._utils.post_infraction")
async def test_apply_ban_reason_truncation(self, post_infraction_mock, get_active_mock):
"""Should truncate reason for `ctx.guild.ban`."""
get_active_mock.return_value = None
@@ -39,7 +39,7 @@ class TruncationTests(unittest.IsolatedAsyncioTestCase):
self.ctx, {"foo": "bar"}, self.target, self.ctx.guild.ban.return_value
)
- @patch("bot.cogs.moderation.utils.post_infraction")
+ @patch("bot.exts.moderation.infraction._utils.post_infraction")
async def test_apply_kick_reason_truncation(self, post_infraction_mock):
"""Should truncate reason for `Member.kick`."""
post_infraction_mock.return_value = {"foo": "bar"}
diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py
new file mode 100644
index 000000000..5b62463e0
--- /dev/null
+++ b/tests/bot/exts/moderation/infraction/test_utils.py
@@ -0,0 +1,359 @@
+import unittest
+from collections import namedtuple
+from datetime import datetime
+from unittest.mock import AsyncMock, MagicMock, call, patch
+
+from discord import Embed, Forbidden, HTTPException, NotFound
+
+from bot.api import ResponseCodeError
+from bot.constants import Colours, Icons
+from bot.exts.moderation.infraction import _utils as utils
+from tests.helpers import MockBot, MockContext, MockMember, MockUser
+
+
+class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):
+ """Tests Moderation utils."""
+
+ def setUp(self):
+ self.bot = MockBot()
+ self.member = MockMember(id=1234)
+ self.user = MockUser(id=1234)
+ self.ctx = MockContext(bot=self.bot, author=self.member)
+
+ async def test_post_user(self):
+ """Should POST a new user and return the response if successful or otherwise send an error message."""
+ user = MockUser(discriminator=5678, id=1234, name="Test user")
+ not_user = MagicMock(discriminator=3333, id=5678, name="Wrong user")
+ test_cases = [
+ {
+ "user": user,
+ "post_result": "bar",
+ "raise_error": None,
+ "payload": {
+ "discriminator": 5678,
+ "id": self.user.id,
+ "in_guild": False,
+ "name": "Test user",
+ "roles": []
+ }
+ },
+ {
+ "user": self.member,
+ "post_result": "foo",
+ "raise_error": ResponseCodeError(MagicMock(status=400), "foo"),
+ "payload": {
+ "discriminator": 0,
+ "id": self.member.id,
+ "in_guild": False,
+ "name": "Name unknown",
+ "roles": []
+ }
+ },
+ {
+ "user": not_user,
+ "post_result": "bar",
+ "raise_error": None,
+ "payload": {
+ "discriminator": not_user.discriminator,
+ "id": not_user.id,
+ "in_guild": False,
+ "name": not_user.name,
+ "roles": []
+ }
+ }
+ ]
+
+ for case in test_cases:
+ user = case["user"]
+ post_result = case["post_result"]
+ raise_error = case["raise_error"]
+ payload = case["payload"]
+
+ with self.subTest(user=user, post_result=post_result, raise_error=raise_error, payload=payload):
+ self.bot.api_client.post.reset_mock(side_effect=True)
+ self.ctx.bot.api_client.post.return_value = post_result
+
+ self.ctx.bot.api_client.post.side_effect = raise_error
+
+ result = await utils.post_user(self.ctx, user)
+
+ if raise_error:
+ self.assertIsNone(result)
+ self.ctx.send.assert_awaited_once()
+ self.assertIn(str(raise_error.status), self.ctx.send.call_args[0][0])
+ else:
+ self.assertEqual(result, post_result)
+ self.bot.api_client.post.assert_awaited_once_with("bot/users", json=payload)
+
+ async def test_get_active_infraction(self):
+ """
+ Should request the API for active infractions and return infraction if the user has one or `None` otherwise.
+
+ A message should be sent to the context indicating a user already has an infraction, if that's the case.
+ """
+ test_case = namedtuple("test_case", ["get_return_value", "expected_output", "infraction_nr", "send_msg"])
+ test_cases = [
+ test_case([], None, None, True),
+ test_case([{"id": 123987}], {"id": 123987}, "123987", False),
+ test_case([{"id": 123987}], {"id": 123987}, "123987", True)
+ ]
+
+ for case in test_cases:
+ with self.subTest(return_value=case.get_return_value, expected=case.expected_output):
+ self.bot.api_client.get.reset_mock()
+ self.ctx.send.reset_mock()
+
+ params = {
+ "active": "true",
+ "type": "ban",
+ "user__id": str(self.member.id)
+ }
+
+ self.bot.api_client.get.return_value = case.get_return_value
+
+ result = await utils.get_active_infraction(self.ctx, self.member, "ban", send_msg=case.send_msg)
+ self.assertEqual(result, case.expected_output)
+ self.bot.api_client.get.assert_awaited_once_with("bot/infractions", params=params)
+
+ if case.send_msg and case.get_return_value:
+ self.ctx.send.assert_awaited_once()
+ sent_message = self.ctx.send.call_args[0][0]
+ self.assertIn(case.infraction_nr, sent_message)
+ self.assertIn("ban", sent_message)
+ else:
+ self.ctx.send.assert_not_awaited()
+
+ @patch("bot.exts.moderation.infraction._utils.send_private_embed")
+ async def test_notify_infraction(self, send_private_embed_mock):
+ """
+ Should send an embed of a certain format as a DM and return `True` if DM successful.
+
+ Appealable infractions should have the appeal message in the embed's footer.
+ """
+ test_cases = [
+ {
+ "args": (self.user, "ban", "2020-02-26 09:20 (23 hours and 59 minutes)"),
+ "expected_output": Embed(
+ title=utils.INFRACTION_TITLE,
+ description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format(
+ type="Ban",
+ expires="2020-02-26 09:20 (23 hours and 59 minutes)",
+ reason="No reason provided."
+ ),
+ colour=Colours.soft_red,
+ url=utils.RULES_URL
+ ).set_author(
+ name=utils.INFRACTION_AUTHOR_NAME,
+ url=utils.RULES_URL,
+ icon_url=Icons.token_removed
+ ).set_footer(text=utils.INFRACTION_APPEAL_FOOTER),
+ "send_result": True
+ },
+ {
+ "args": (self.user, "warning", None, "Test reason."),
+ "expected_output": Embed(
+ title=utils.INFRACTION_TITLE,
+ description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format(
+ type="Warning",
+ expires="N/A",
+ reason="Test reason."
+ ),
+ colour=Colours.soft_red,
+ url=utils.RULES_URL
+ ).set_author(
+ name=utils.INFRACTION_AUTHOR_NAME,
+ url=utils.RULES_URL,
+ icon_url=Icons.token_removed
+ ),
+ "send_result": False
+ },
+ {
+ "args": (self.user, "note", None, None, Icons.defcon_denied),
+ "expected_output": Embed(
+ title=utils.INFRACTION_TITLE,
+ description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format(
+ type="Note",
+ expires="N/A",
+ reason="No reason provided."
+ ),
+ colour=Colours.soft_red,
+ url=utils.RULES_URL
+ ).set_author(
+ name=utils.INFRACTION_AUTHOR_NAME,
+ url=utils.RULES_URL,
+ icon_url=Icons.defcon_denied
+ ),
+ "send_result": False
+ },
+ {
+ "args": (self.user, "mute", "2020-02-26 09:20 (23 hours and 59 minutes)", "Test", Icons.defcon_denied),
+ "expected_output": Embed(
+ title=utils.INFRACTION_TITLE,
+ description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format(
+ type="Mute",
+ expires="2020-02-26 09:20 (23 hours and 59 minutes)",
+ reason="Test"
+ ),
+ colour=Colours.soft_red,
+ url=utils.RULES_URL
+ ).set_author(
+ name=utils.INFRACTION_AUTHOR_NAME,
+ url=utils.RULES_URL,
+ icon_url=Icons.defcon_denied
+ ).set_footer(text=utils.INFRACTION_APPEAL_FOOTER),
+ "send_result": False
+ },
+ {
+ "args": (self.user, "mute", None, "foo bar" * 4000, Icons.defcon_denied),
+ "expected_output": Embed(
+ title=utils.INFRACTION_TITLE,
+ description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format(
+ type="Mute",
+ expires="N/A",
+ reason="foo bar" * 4000
+ )[:2045] + "...",
+ colour=Colours.soft_red,
+ url=utils.RULES_URL
+ ).set_author(
+ name=utils.INFRACTION_AUTHOR_NAME,
+ url=utils.RULES_URL,
+ icon_url=Icons.defcon_denied
+ ).set_footer(text=utils.INFRACTION_APPEAL_FOOTER),
+ "send_result": True
+ }
+ ]
+
+ for case in test_cases:
+ with self.subTest(args=case["args"], expected=case["expected_output"], send=case["send_result"]):
+ send_private_embed_mock.reset_mock()
+
+ send_private_embed_mock.return_value = case["send_result"]
+ result = await utils.notify_infraction(*case["args"])
+
+ self.assertEqual(case["send_result"], result)
+
+ embed = send_private_embed_mock.call_args[0][1]
+
+ self.assertEqual(embed.to_dict(), case["expected_output"].to_dict())
+
+ send_private_embed_mock.assert_awaited_once_with(case["args"][0], embed)
+
+ @patch("bot.exts.moderation.infraction._utils.send_private_embed")
+ async def test_notify_pardon(self, send_private_embed_mock):
+ """Should send an embed of a certain format as a DM and return `True` if DM successful."""
+ test_case = namedtuple("test_case", ["args", "icon", "send_result"])
+ test_cases = [
+ test_case((self.user, "Test title", "Example content"), Icons.user_verified, True),
+ test_case((self.user, "Test title", "Example content", Icons.user_update), Icons.user_update, False)
+ ]
+
+ for case in test_cases:
+ expected = Embed(
+ description="Example content",
+ colour=Colours.soft_green
+ ).set_author(
+ name="Test title",
+ icon_url=case.icon
+ )
+
+ with self.subTest(args=case.args, expected=expected):
+ send_private_embed_mock.reset_mock()
+
+ send_private_embed_mock.return_value = case.send_result
+
+ result = await utils.notify_pardon(*case.args)
+ self.assertEqual(case.send_result, result)
+
+ embed = send_private_embed_mock.call_args[0][1]
+ self.assertEqual(embed.to_dict(), expected.to_dict())
+
+ send_private_embed_mock.assert_awaited_once_with(case.args[0], embed)
+
+ async def test_send_private_embed(self):
+ """Should DM the user and return `True` on success or `False` on failure."""
+ embed = Embed(title="Test", description="Test val")
+
+ test_case = namedtuple("test_case", ["expected_output", "raised_exception"])
+ test_cases = [
+ test_case(True, None),
+ test_case(False, HTTPException(AsyncMock(), AsyncMock())),
+ test_case(False, Forbidden(AsyncMock(), AsyncMock())),
+ test_case(False, NotFound(AsyncMock(), AsyncMock()))
+ ]
+
+ for case in test_cases:
+ with self.subTest(expected=case.expected_output, raised=case.raised_exception):
+ self.user.send.reset_mock(side_effect=True)
+ self.user.send.side_effect = case.raised_exception
+
+ result = await utils.send_private_embed(self.user, embed)
+
+ self.assertEqual(result, case.expected_output)
+ self.user.send.assert_awaited_once_with(embed=embed)
+
+
+class TestPostInfraction(unittest.IsolatedAsyncioTestCase):
+ """Tests for the `post_infraction` function."""
+
+ def setUp(self):
+ self.bot = MockBot()
+ self.member = MockMember(id=1234)
+ self.user = MockUser(id=1234)
+ self.ctx = MockContext(bot=self.bot, author=self.member)
+
+ async def test_normal_post_infraction(self):
+ """Should return response from POST request if there are no errors."""
+ now = datetime.now()
+ payload = {
+ "actor": self.ctx.author.id,
+ "hidden": True,
+ "reason": "Test reason",
+ "type": "ban",
+ "user": self.member.id,
+ "active": False,
+ "expires_at": now.isoformat()
+ }
+
+ self.ctx.bot.api_client.post.return_value = "foo"
+ actual = await utils.post_infraction(self.ctx, self.member, "ban", "Test reason", now, True, False)
+
+ self.assertEqual(actual, "foo")
+ self.ctx.bot.api_client.post.assert_awaited_once_with("bot/infractions", json=payload)
+
+ async def test_unknown_error_post_infraction(self):
+ """Should send an error message to chat when a non-400 error occurs."""
+ self.ctx.bot.api_client.post.side_effect = ResponseCodeError(AsyncMock(), AsyncMock())
+ self.ctx.bot.api_client.post.side_effect.status = 500
+
+ actual = await utils.post_infraction(self.ctx, self.user, "ban", "Test reason")
+ self.assertIsNone(actual)
+
+ self.assertTrue("500" in self.ctx.send.call_args[0][0])
+
+ @patch("bot.exts.moderation.infraction._utils.post_user", return_value=None)
+ async def test_user_not_found_none_post_infraction(self, post_user_mock):
+ """Should abort and return `None` when a new user fails to be posted."""
+ self.bot.api_client.post.side_effect = ResponseCodeError(MagicMock(status=400), {"user": "foo"})
+
+ actual = await utils.post_infraction(self.ctx, self.user, "mute", "Test reason")
+ self.assertIsNone(actual)
+ post_user_mock.assert_awaited_once_with(self.ctx, self.user)
+
+ @patch("bot.exts.moderation.infraction._utils.post_user", return_value="bar")
+ async def test_first_fail_second_success_user_post_infraction(self, post_user_mock):
+ """Should post the user if they don't exist, POST infraction again, and return the response if successful."""
+ payload = {
+ "actor": self.ctx.author.id,
+ "hidden": False,
+ "reason": "Test reason",
+ "type": "mute",
+ "user": self.user.id,
+ "active": True
+ }
+
+ self.bot.api_client.post.side_effect = [ResponseCodeError(MagicMock(status=400), {"user": "foo"}), "foo"]
+
+ actual = await utils.post_infraction(self.ctx, self.user, "mute", "Test reason")
+ self.assertEqual(actual, "foo")
+ self.bot.api_client.post.assert_has_awaits([call("bot/infractions", json=payload)] * 2)
+ post_user_mock.assert_awaited_once_with(self.ctx, self.user)
diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py
index 435a1cd51..cbf7f7bcf 100644
--- a/tests/bot/cogs/moderation/test_incidents.py
+++ b/tests/bot/exts/moderation/test_incidents.py
@@ -8,8 +8,8 @@ from unittest.mock import AsyncMock, MagicMock, call, patch
import aiohttp
import discord
-from bot.cogs.moderation import Incidents, incidents
from bot.constants import Colours
+from bot.exts.moderation import incidents
from tests.helpers import (
MockAsyncWebhook,
MockAttachment,
@@ -130,7 +130,7 @@ class TestMakeEmbed(unittest.IsolatedAsyncioTestCase):
incident = MockMessage(content="this is an incident", attachments=[attachment])
# Patch `download_file` to return our `file`
- with patch("bot.cogs.moderation.incidents.download_file", AsyncMock(return_value=file)):
+ with patch("bot.exts.moderation.incidents.download_file", AsyncMock(return_value=file)):
embed, returned_file = await incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember())
self.assertIs(file, returned_file)
@@ -142,7 +142,7 @@ class TestMakeEmbed(unittest.IsolatedAsyncioTestCase):
incident = MockMessage(content="this is an incident", attachments=[attachment])
# Patch `download_file` to return None as if the download failed
- with patch("bot.cogs.moderation.incidents.download_file", AsyncMock(return_value=None)):
+ with patch("bot.exts.moderation.incidents.download_file", AsyncMock(return_value=None)):
embed, returned_file = await incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember())
self.assertIsNone(returned_file)
@@ -215,7 +215,7 @@ class TestOwnReactions(unittest.TestCase):
self.assertSetEqual(incidents.own_reactions(message), {"A", "B"})
-@patch("bot.cogs.moderation.incidents.ALL_SIGNALS", {"A", "B"})
+@patch("bot.exts.moderation.incidents.ALL_SIGNALS", {"A", "B"})
class TestHasSignals(unittest.TestCase):
"""
Assertions for the `has_signals` function.
@@ -229,7 +229,7 @@ class TestHasSignals(unittest.TestCase):
message = MockMessage()
own_reactions = MagicMock(return_value={"A", "B"})
- with patch("bot.cogs.moderation.incidents.own_reactions", own_reactions):
+ with patch("bot.exts.moderation.incidents.own_reactions", own_reactions):
self.assertTrue(incidents.has_signals(message))
def test_has_signals_false(self):
@@ -237,11 +237,11 @@ class TestHasSignals(unittest.TestCase):
message = MockMessage()
own_reactions = MagicMock(return_value={"A", "C"})
- with patch("bot.cogs.moderation.incidents.own_reactions", own_reactions):
+ with patch("bot.exts.moderation.incidents.own_reactions", own_reactions):
self.assertFalse(incidents.has_signals(message))
-@patch("bot.cogs.moderation.incidents.Signal", MockSignal)
+@patch("bot.exts.moderation.incidents.Signal", MockSignal)
class TestAddSignals(unittest.IsolatedAsyncioTestCase):
"""
Assertions for the `add_signals` coroutine.
@@ -255,19 +255,19 @@ class TestAddSignals(unittest.IsolatedAsyncioTestCase):
"""Prepare a mock incident message for tests to use."""
self.incident = MockMessage()
- @patch("bot.cogs.moderation.incidents.own_reactions", MagicMock(return_value=set()))
+ @patch("bot.exts.moderation.incidents.own_reactions", MagicMock(return_value=set()))
async def test_add_signals_missing(self):
"""All emoji are added when none are present."""
await incidents.add_signals(self.incident)
self.incident.add_reaction.assert_has_calls([call("A"), call("B")])
- @patch("bot.cogs.moderation.incidents.own_reactions", MagicMock(return_value={"A"}))
+ @patch("bot.exts.moderation.incidents.own_reactions", MagicMock(return_value={"A"}))
async def test_add_signals_partial(self):
"""Only missing emoji are added when some are present."""
await incidents.add_signals(self.incident)
self.incident.add_reaction.assert_has_calls([call("B")])
- @patch("bot.cogs.moderation.incidents.own_reactions", MagicMock(return_value={"A", "B"}))
+ @patch("bot.exts.moderation.incidents.own_reactions", MagicMock(return_value={"A", "B"}))
async def test_add_signals_present(self):
"""No emoji are added when all are present."""
await incidents.add_signals(self.incident)
@@ -290,7 +290,7 @@ class TestIncidents(unittest.IsolatedAsyncioTestCase):
Note that this will not schedule `crawl_incidents` in the background, as everything
is being mocked. The `crawl_task` attribute will end up being None.
"""
- self.cog_instance = Incidents(MockBot())
+ self.cog_instance = incidents.Incidents(MockBot())
@patch("asyncio.sleep", AsyncMock()) # Prevent the coro from sleeping to speed up the test
@@ -326,25 +326,25 @@ class TestCrawlIncidents(TestIncidents):
await self.cog_instance.crawl_incidents()
self.cog_instance.bot.wait_until_guild_available.assert_awaited()
- @patch("bot.cogs.moderation.incidents.add_signals", AsyncMock())
- @patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=False)) # Message doesn't qualify
- @patch("bot.cogs.moderation.incidents.has_signals", MagicMock(return_value=False))
+ @patch("bot.exts.moderation.incidents.add_signals", AsyncMock())
+ @patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=False)) # Message doesn't qualify
+ @patch("bot.exts.moderation.incidents.has_signals", MagicMock(return_value=False))
async def test_crawl_incidents_noop_if_is_not_incident(self):
"""Signals are not added for a non-incident message."""
await self.cog_instance.crawl_incidents()
incidents.add_signals.assert_not_awaited()
- @patch("bot.cogs.moderation.incidents.add_signals", AsyncMock())
- @patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=True)) # Message qualifies
- @patch("bot.cogs.moderation.incidents.has_signals", MagicMock(return_value=True)) # But already has signals
+ @patch("bot.exts.moderation.incidents.add_signals", AsyncMock())
+ @patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=True)) # Message qualifies
+ @patch("bot.exts.moderation.incidents.has_signals", MagicMock(return_value=True)) # But already has signals
async def test_crawl_incidents_noop_if_message_already_has_signals(self):
"""Signals are not added for messages which already have them."""
await self.cog_instance.crawl_incidents()
incidents.add_signals.assert_not_awaited()
- @patch("bot.cogs.moderation.incidents.add_signals", AsyncMock())
- @patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=True)) # Message qualifies
- @patch("bot.cogs.moderation.incidents.has_signals", MagicMock(return_value=False)) # And doesn't have signals
+ @patch("bot.exts.moderation.incidents.add_signals", AsyncMock())
+ @patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=True)) # Message qualifies
+ @patch("bot.exts.moderation.incidents.has_signals", MagicMock(return_value=False)) # And doesn't have signals
async def test_crawl_incidents_add_signals_called(self):
"""Message has signals added as it does not have them yet and qualifies as an incident."""
await self.cog_instance.crawl_incidents()
@@ -384,7 +384,7 @@ class TestArchive(TestIncidents):
)
built_embed = MagicMock(discord.Embed, id=123) # We patch `make_embed` to return this
- with patch("bot.cogs.moderation.incidents.make_embed", AsyncMock(return_value=(built_embed, None))):
+ with patch("bot.exts.moderation.incidents.make_embed", AsyncMock(return_value=(built_embed, None))):
archive_return = await self.cog_instance.archive(incident, MagicMock(value="A"), MockMember())
# Now we check that the webhook was given the correct args, and that `archive` returned True
@@ -451,8 +451,8 @@ class TestMakeConfirmationTask(TestIncidents):
self.assertFalse(created_check(payload=MagicMock(message_id=0)))
-@patch("bot.cogs.moderation.incidents.ALLOWED_ROLES", {1, 2})
-@patch("bot.cogs.moderation.incidents.Incidents.make_confirmation_task", AsyncMock()) # Generic awaitable
+@patch("bot.exts.moderation.incidents.ALLOWED_ROLES", {1, 2})
+@patch("bot.exts.moderation.incidents.Incidents.make_confirmation_task", AsyncMock()) # Generic awaitable
class TestProcessEvent(TestIncidents):
"""Tests for the `Incidents.process_event` coroutine."""
@@ -479,7 +479,7 @@ class TestProcessEvent(TestIncidents):
async def test_process_event_no_archive_on_investigating(self):
"""Message is not archived on `Signal.INVESTIGATING`."""
- with patch("bot.cogs.moderation.incidents.Incidents.archive", AsyncMock()) as mocked_archive:
+ with patch("bot.exts.moderation.incidents.Incidents.archive", AsyncMock()) as mocked_archive:
await self.cog_instance.process_event(
reaction=incidents.Signal.INVESTIGATING.value,
incident=MockMessage(),
@@ -497,7 +497,7 @@ class TestProcessEvent(TestIncidents):
"""
incident = MockMessage()
- with patch("bot.cogs.moderation.incidents.Incidents.archive", AsyncMock(return_value=False)):
+ with patch("bot.exts.moderation.incidents.Incidents.archive", AsyncMock(return_value=False)):
await self.cog_instance.process_event(
reaction=incidents.Signal.ACTIONED.value,
incident=incident,
@@ -510,7 +510,7 @@ class TestProcessEvent(TestIncidents):
"""Task given by `Incidents.make_confirmation_task` is awaited before method exits."""
mock_task = AsyncMock()
- with patch("bot.cogs.moderation.incidents.Incidents.make_confirmation_task", mock_task):
+ with patch("bot.exts.moderation.incidents.Incidents.make_confirmation_task", mock_task):
await self.cog_instance.process_event(
reaction=incidents.Signal.ACTIONED.value,
incident=MockMessage(),
@@ -530,7 +530,7 @@ class TestProcessEvent(TestIncidents):
mock_task = AsyncMock(side_effect=asyncio.TimeoutError())
try:
- with patch("bot.cogs.moderation.incidents.Incidents.make_confirmation_task", mock_task):
+ with patch("bot.exts.moderation.incidents.Incidents.make_confirmation_task", mock_task):
await self.cog_instance.process_event(
reaction=incidents.Signal.ACTIONED.value,
incident=MockMessage(),
@@ -712,7 +712,7 @@ class TestOnRawReactionAdd(TestIncidents):
self.cog_instance.process_event = AsyncMock()
self.cog_instance.resolve_message = AsyncMock(return_value=MockMessage())
- with patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=False)):
+ with patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=False)):
await self.cog_instance.on_raw_reaction_add(self.payload)
self.cog_instance.process_event.assert_not_called()
@@ -733,7 +733,7 @@ class TestOnRawReactionAdd(TestIncidents):
self.cog_instance.process_event = AsyncMock()
self.cog_instance.resolve_message = AsyncMock(return_value=incident)
- with patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=True)):
+ with patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=True)):
await self.cog_instance.on_raw_reaction_add(self.payload)
self.cog_instance.process_event.assert_called_with(
@@ -751,20 +751,20 @@ class TestOnMessage(TestIncidents):
function is tested in `TestIsIncident` - here we do not worry about it.
"""
- @patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=True))
+ @patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=True))
async def test_on_message_incident(self):
"""Messages qualifying as incidents are passed to `add_signals`."""
incident = MockMessage()
- with patch("bot.cogs.moderation.incidents.add_signals", AsyncMock()) as mock_add_signals:
+ with patch("bot.exts.moderation.incidents.add_signals", AsyncMock()) as mock_add_signals:
await self.cog_instance.on_message(incident)
mock_add_signals.assert_called_once_with(incident)
- @patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=False))
+ @patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=False))
async def test_on_message_non_incident(self):
"""Messages not qualifying as incidents are ignored."""
- with patch("bot.cogs.moderation.incidents.add_signals", AsyncMock()) as mock_add_signals:
+ with patch("bot.exts.moderation.incidents.add_signals", AsyncMock()) as mock_add_signals:
await self.cog_instance.on_message(MockMessage())
mock_add_signals.assert_not_called()
diff --git a/tests/bot/cogs/moderation/test_modlog.py b/tests/bot/exts/moderation/test_modlog.py
index f2809f40a..f8f142484 100644
--- a/tests/bot/cogs/moderation/test_modlog.py
+++ b/tests/bot/exts/moderation/test_modlog.py
@@ -2,7 +2,7 @@ import unittest
import discord
-from bot.cogs.moderation.modlog import ModLog
+from bot.exts.moderation.modlog import ModLog
from tests.helpers import MockBot, MockTextChannel
diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py
index ab3d0742a..e2d44c637 100644
--- a/tests/bot/cogs/moderation/test_silence.py
+++ b/tests/bot/exts/moderation/test_silence.py
@@ -4,8 +4,8 @@ from unittest.mock import MagicMock, Mock
from discord import PermissionOverwrite
-from bot.cogs.moderation.silence import Silence, SilenceNotifier
from bot.constants import Channels, Emojis, Guild, Roles
+from bot.exts.moderation.silence import Silence, SilenceNotifier
from tests.helpers import MockBot, MockContext, MockTextChannel
@@ -99,7 +99,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase):
self.bot.get_channel.called_once_with(Channels.mod_alerts)
self.bot.get_channel.called_once_with(Channels.mod_log)
- @mock.patch("bot.cogs.moderation.silence.SilenceNotifier")
+ @mock.patch("bot.exts.moderation.silence.SilenceNotifier")
async def test_instance_vars_got_notifier(self, notifier):
"""Notifier was started with channel."""
mod_log = MockTextChannel()
@@ -238,7 +238,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase):
del mock_permissions_dict['send_messages']
self.assertDictEqual(mock_permissions_dict, new_permissions)
- @mock.patch("bot.cogs.moderation.silence.asyncio")
+ @mock.patch("bot.exts.moderation.silence.asyncio")
@mock.patch.object(Silence, "_mod_alerts_channel", create=True)
def test_cog_unload_starts_task(self, alert_channel, asyncio_mock):
"""Task for sending an alert was created with present `muted_channels`."""
@@ -247,15 +247,17 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase):
alert_channel.send.assert_called_once_with(f"<@&{Roles.moderators}> channels left silenced on cog unload: ")
asyncio_mock.create_task.assert_called_once_with(alert_channel.send())
- @mock.patch("bot.cogs.moderation.silence.asyncio")
+ @mock.patch("bot.exts.moderation.silence.asyncio")
def test_cog_unload_skips_task_start(self, asyncio_mock):
"""No task created with no channels."""
self.cog.cog_unload()
asyncio_mock.create_task.assert_not_called()
- @mock.patch("bot.cogs.moderation.silence.with_role_check")
- @mock.patch("bot.cogs.moderation.silence.MODERATION_ROLES", new=(1, 2, 3))
- def test_cog_check(self, role_check):
+ @mock.patch("discord.ext.commands.has_any_role")
+ @mock.patch("bot.exts.moderation.silence.MODERATION_ROLES", new=(1, 2, 3))
+ async def test_cog_check(self, role_check):
"""Role check is called with `MODERATION_ROLES`"""
- self.cog.cog_check(self.ctx)
- role_check.assert_called_once_with(self.ctx, *(1, 2, 3))
+ role_check.return_value.predicate = mock.AsyncMock()
+ await self.cog.cog_check(self.ctx)
+ role_check.assert_called_once_with(*(1, 2, 3))
+ role_check.return_value.predicate.assert_awaited_once_with(self.ctx)
diff --git a/tests/bot/cogs/test_slowmode.py b/tests/bot/exts/moderation/test_slowmode.py
index f442814c8..dad751e0d 100644
--- a/tests/bot/cogs/test_slowmode.py
+++ b/tests/bot/exts/moderation/test_slowmode.py
@@ -3,8 +3,8 @@ from unittest import mock
from dateutil.relativedelta import relativedelta
-from bot.cogs.moderation.slowmode import Slowmode
from bot.constants import Emojis
+from bot.exts.moderation.slowmode import Slowmode
from tests.helpers import MockBot, MockContext, MockTextChannel
@@ -103,9 +103,11 @@ class SlowmodeTests(unittest.IsolatedAsyncioTestCase):
f'{Emojis.check_mark} The slowmode delay for #meta has been reset to 0 seconds.'
)
- @mock.patch("bot.cogs.moderation.slowmode.with_role_check")
- @mock.patch("bot.cogs.moderation.slowmode.MODERATION_ROLES", new=(1, 2, 3))
- def test_cog_check(self, role_check):
+ @mock.patch("bot.exts.moderation.slowmode.has_any_role")
+ @mock.patch("bot.exts.moderation.slowmode.MODERATION_ROLES", new=(1, 2, 3))
+ async def test_cog_check(self, role_check):
"""Role check is called with `MODERATION_ROLES`"""
- self.cog.cog_check(self.ctx)
- role_check.assert_called_once_with(self.ctx, *(1, 2, 3))
+ role_check.return_value.predicate = mock.AsyncMock()
+ await self.cog.cog_check(self.ctx)
+ role_check.assert_called_once_with(*(1, 2, 3))
+ role_check.return_value.predicate.assert_awaited_once_with(self.ctx)
diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/exts/test_cogs.py
index 30a04422a..f8e120262 100644
--- a/tests/bot/cogs/test_cogs.py
+++ b/tests/bot/exts/test_cogs.py
@@ -10,7 +10,7 @@ from unittest import mock
from discord.ext import commands
-from bot import cogs
+from bot import exts
class CommandNameTests(unittest.TestCase):
@@ -29,13 +29,14 @@ class CommandNameTests(unittest.TestCase):
@staticmethod
def walk_modules() -> t.Iterator[ModuleType]:
- """Yield imported modules from the bot.cogs subpackage."""
+ """Yield imported modules from the bot.exts subpackage."""
def on_error(name: str) -> t.NoReturn:
raise ImportError(name=name) # pragma: no cover
# The mock prevents asyncio.get_event_loop() from being called.
with mock.patch("discord.ext.tasks.loop"):
- for module in pkgutil.walk_packages(cogs.__path__, "bot.cogs.", onerror=on_error):
+ prefix = f"{exts.__name__}."
+ for module in pkgutil.walk_packages(exts.__path__, prefix, onerror=on_error):
if not module.ispkg:
yield importlib.import_module(module.name)
diff --git a/tests/bot/exts/utils/__init__.py b/tests/bot/exts/utils/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/tests/bot/exts/utils/__init__.py
diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/exts/utils/test_jams.py
index b4ad8535f..45e7b5b51 100644
--- a/tests/bot/cogs/test_jams.py
+++ b/tests/bot/exts/utils/test_jams.py
@@ -3,8 +3,8 @@ from unittest.mock import AsyncMock, MagicMock, create_autospec
from discord import CategoryChannel
-from bot.cogs import jams
from bot.constants import Roles
+from bot.exts.utils import jams
from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole, MockTextChannel
diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/exts/utils/test_snekbox.py
index f22952931..40b2202aa 100644
--- a/tests/bot/cogs/test_snekbox.py
+++ b/tests/bot/exts/utils/test_snekbox.py
@@ -5,8 +5,8 @@ from unittest.mock import AsyncMock, MagicMock, Mock, call, create_autospec, pat
from discord.ext import commands
from bot import constants
-from bot.cogs import snekbox
-from bot.cogs.snekbox import Snekbox
+from bot.exts.utils import snekbox
+from bot.exts.utils.snekbox import Snekbox
from tests.helpers import MockBot, MockContext, MockMessage, MockReaction, MockUser
@@ -38,7 +38,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
result = await self.cog.upload_output("-" * (snekbox.MAX_PASTE_LEN + 1))
self.assertEqual(result, "too long to upload")
- @patch("bot.cogs.snekbox.send_to_paste_service")
+ @patch("bot.exts.utils.snekbox.send_to_paste_service")
async def test_upload_output(self, mock_paste_util):
"""Upload the eval output to the URLs.paste_service.format(key="documents") endpoint."""
await self.cog.upload_output("Test output.")
@@ -69,14 +69,14 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
actual = self.cog.get_results_message({'stdout': stdout, 'returncode': returncode})
self.assertEqual(actual, expected)
- @patch('bot.cogs.snekbox.Signals', side_effect=ValueError)
+ @patch('bot.exts.utils.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')
+ @patch('bot.exts.utils.snekbox.Signals')
def test_get_results_message_valid_signal(self, mock_signals: Mock):
mock_signals.return_value.name = 'SIGTEST'
self.assertEqual(
@@ -117,12 +117,12 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
('<!@', ("<!@\u200B", None), r'Convert <!@ to <!@\u200B'),
(
'\u202E\u202E\u202E',
- ('Code block escape attempt detected; will not output result', None),
+ ('Code block escape attempt detected; will not output result', 'https://testificate.com/'),
'Detect RIGHT-TO-LEFT OVERRIDE'
),
(
'\u200B\u200B\u200B',
- ('Code block escape attempt detected; will not output result', None),
+ ('Code block escape attempt detected; will not output result', 'https://testificate.com/'),
'Detect ZERO WIDTH SPACE'
),
('long\nbeard', ('001 | long\n002 | beard', None), 'Two line output'),
@@ -266,7 +266,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
self.cog.get_results_message.assert_called_once_with({'stdout': 'ERROR', 'returncode': 127})
self.cog.format_output.assert_not_called()
- @patch("bot.cogs.snekbox.partial")
+ @patch("bot.exts.utils.snekbox.partial")
async def test_continue_eval_does_continue(self, partial_mock):
"""Test that the continue_eval function does continue if required conditions are met."""
ctx = MockContext(message=MockMessage(add_reaction=AsyncMock(), clear_reactions=AsyncMock()))
diff --git a/tests/bot/utils/test_checks.py b/tests/bot/utils/test_checks.py
index de72e5748..883465e0b 100644
--- a/tests/bot/utils/test_checks.py
+++ b/tests/bot/utils/test_checks.py
@@ -1,48 +1,50 @@
import unittest
from unittest.mock import MagicMock
+from discord import DMChannel
+
from bot.utils import checks
from bot.utils.checks import InWhitelistCheckFailure
from tests.helpers import MockContext, MockRole
-class ChecksTests(unittest.TestCase):
+class ChecksTests(unittest.IsolatedAsyncioTestCase):
"""Tests the check functions defined in `bot.checks`."""
def setUp(self):
self.ctx = MockContext()
- def test_with_role_check_without_guild(self):
- """`with_role_check` returns `False` if `Context.guild` is None."""
- self.ctx.guild = None
- self.assertFalse(checks.with_role_check(self.ctx))
+ async def test_has_any_role_check_without_guild(self):
+ """`has_any_role_check` returns `False` for non-guild channels."""
+ self.ctx.channel = MagicMock(DMChannel)
+ self.assertFalse(await checks.has_any_role_check(self.ctx))
- def test_with_role_check_without_required_roles(self):
- """`with_role_check` returns `False` if `Context.author` lacks the required role."""
+ async def test_has_any_role_check_without_required_roles(self):
+ """`has_any_role_check` returns `False` if `Context.author` lacks the required role."""
self.ctx.author.roles = []
- self.assertFalse(checks.with_role_check(self.ctx))
+ self.assertFalse(await checks.has_any_role_check(self.ctx))
- def test_with_role_check_with_guild_and_required_role(self):
- """`with_role_check` returns `True` if `Context.author` has the required role."""
+ async def test_has_any_role_check_with_guild_and_required_role(self):
+ """`has_any_role_check` returns `True` if `Context.author` has the required role."""
self.ctx.author.roles.append(MockRole(id=10))
- self.assertTrue(checks.with_role_check(self.ctx, 10))
+ self.assertTrue(await checks.has_any_role_check(self.ctx, 10))
- def test_without_role_check_without_guild(self):
- """`without_role_check` should return `False` when `Context.guild` is None."""
- self.ctx.guild = None
- self.assertFalse(checks.without_role_check(self.ctx))
+ async def test_has_no_roles_check_without_guild(self):
+ """`has_no_roles_check` should return `False` when `Context.guild` is None."""
+ self.ctx.channel = MagicMock(DMChannel)
+ self.assertFalse(await checks.has_no_roles_check(self.ctx))
- def test_without_role_check_returns_false_with_unwanted_role(self):
- """`without_role_check` returns `False` if `Context.author` has unwanted role."""
+ async def test_has_no_roles_check_returns_false_with_unwanted_role(self):
+ """`has_no_roles_check` returns `False` if `Context.author` has unwanted role."""
role_id = 42
self.ctx.author.roles.append(MockRole(id=role_id))
- self.assertFalse(checks.without_role_check(self.ctx, role_id))
+ self.assertFalse(await checks.has_no_roles_check(self.ctx, role_id))
- def test_without_role_check_returns_true_without_unwanted_role(self):
- """`without_role_check` returns `True` if `Context.author` does not have unwanted role."""
+ async def test_has_no_roles_check_returns_true_without_unwanted_role(self):
+ """`has_no_roles_check` returns `True` if `Context.author` does not have unwanted role."""
role_id = 42
self.ctx.author.roles.append(MockRole(id=role_id))
- self.assertTrue(checks.without_role_check(self.ctx, role_id + 10))
+ self.assertTrue(await checks.has_no_roles_check(self.ctx, role_id + 10))
def test_in_whitelist_check_correct_channel(self):
"""`in_whitelist_check` returns `True` if `Context.channel.id` is in the channel list."""