aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar MarkKoz <[email protected]>2020-09-09 14:07:55 -0700
committerGravatar MarkKoz <[email protected]>2020-09-09 14:07:55 -0700
commit899e5bf221dc74d62ef05ac6fbf4d8b34112e40d (patch)
treeaf72bde3cbf48f3ca06164fc0cc5f1c338b7fd35
parentCode block: make _get_leading_spaces more readable (diff)
parentMerge pull request #1144 from Numerlor/text-link-fix (diff)
Fix conflict for webhook token check in code block detection
-rw-r--r--bot/__init__.py10
-rw-r--r--bot/__main__.py1
-rw-r--r--bot/bot.py41
-rw-r--r--bot/cogs/alias.py70
-rw-r--r--bot/cogs/antimalware.py4
-rw-r--r--bot/cogs/antispam.py8
-rw-r--r--bot/cogs/codeblock/cog.py4
-rw-r--r--bot/cogs/defcon.py16
-rw-r--r--bot/cogs/extensions.py2
-rw-r--r--bot/cogs/help.py38
-rw-r--r--bot/cogs/help_channels.py4
-rw-r--r--bot/cogs/site.py10
-rw-r--r--bot/cogs/watchchannels/bigbrother.py4
-rw-r--r--bot/cogs/watchchannels/talentpool.py6
-rw-r--r--bot/cogs/watchchannels/watchchannel.py10
-rw-r--r--bot/cogs/wolfram.py280
-rw-r--r--bot/command.py18
-rw-r--r--bot/constants.py8
-rw-r--r--bot/pagination.py164
-rw-r--r--bot/rules/__init__.py1
-rw-r--r--bot/rules/discord_emojis.py4
-rw-r--r--bot/rules/everyone_ping.py41
-rw-r--r--config-default.yml25
-rw-r--r--tests/bot/cogs/test_antimalware.py22
-rw-r--r--tests/bot/cogs/test_cogs.py1
-rw-r--r--tests/bot/test_pagination.py15
26 files changed, 203 insertions, 604 deletions
diff --git a/bot/__init__.py b/bot/__init__.py
index d63086fe2..3ee70c4e9 100644
--- a/bot/__init__.py
+++ b/bot/__init__.py
@@ -2,10 +2,14 @@ import asyncio
import logging
import os
import sys
+from functools import partial, partialmethod
from logging import Logger, handlers
from pathlib import Path
import coloredlogs
+from discord.ext import commands
+
+from bot.command import Command
TRACE_LEVEL = logging.TRACE = 5
logging.addLevelName(TRACE_LEVEL, "TRACE")
@@ -66,3 +70,9 @@ logging.getLogger(__name__)
# On Windows, the selector event loop is required for aiodns.
if os.name == "nt":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
+
+
+# Monkey-patch discord.py decorators to use the Command subclass which supports root aliases.
+# Must be patched before any cogs are added.
+commands.command = partial(commands.command, cls=Command)
+commands.GroupMixin.command = partialmethod(commands.GroupMixin.command, cls=Command)
diff --git a/bot/__main__.py b/bot/__main__.py
index aafaab425..7ef441873 100644
--- a/bot/__main__.py
+++ b/bot/__main__.py
@@ -75,7 +75,6 @@ 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")
-bot.load_extension("bot.cogs.wolfram")
if constants.HelpChannels.enable:
bot.load_extension("bot.cogs.help_channels")
diff --git a/bot/bot.py b/bot/bot.py
index 756449293..d25074fd9 100644
--- a/bot/bot.py
+++ b/bot/bot.py
@@ -130,6 +130,26 @@ class Bot(commands.Bot):
super().add_cog(cog)
log.info(f"Cog loaded: {cog.qualified_name}")
+ def add_command(self, command: commands.Command) -> None:
+ """Add `command` as normal and then add its root aliases to the bot."""
+ super().add_command(command)
+ self._add_root_aliases(command)
+
+ def remove_command(self, name: str) -> Optional[commands.Command]:
+ """
+ Remove a command/alias as normal and then remove its root aliases from the bot.
+
+ Individual root aliases cannot be removed by this function.
+ To remove them, either remove the entire command or manually edit `bot.all_commands`.
+ """
+ command = super().remove_command(name)
+ if command is None:
+ # Even if it's a root alias, there's no way to get the Bot instance to remove the alias.
+ return
+
+ self._remove_root_aliases(command)
+ return command
+
def clear(self) -> None:
"""
Clears the internal state of the bot and recreates the connector and sessions.
@@ -235,3 +255,24 @@ class Bot(commands.Bot):
scope.set_extra("kwargs", kwargs)
log.exception(f"Unhandled exception in {event}.")
+
+ def _add_root_aliases(self, command: commands.Command) -> None:
+ """Recursively add root aliases for `command` and any of its subcommands."""
+ if isinstance(command, commands.Group):
+ for subcommand in command.commands:
+ self._add_root_aliases(subcommand)
+
+ for alias in getattr(command, "root_aliases", ()):
+ if alias in self.all_commands:
+ raise commands.CommandRegistrationError(alias, alias_conflict=True)
+
+ self.all_commands[alias] = command
+
+ def _remove_root_aliases(self, command: commands.Command) -> None:
+ """Recursively remove root aliases for `command` and any of its subcommands."""
+ if isinstance(command, commands.Group):
+ for subcommand in command.commands:
+ self._remove_root_aliases(subcommand)
+
+ for alias in getattr(command, "root_aliases", ()):
+ self.all_commands.pop(alias, None)
diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py
index 55c7efe65..c6ba8d6f3 100644
--- a/bot/cogs/alias.py
+++ b/bot/cogs/alias.py
@@ -3,13 +3,12 @@ import logging
from discord import Colour, Embed
from discord.ext.commands import (
- Cog, Command, Context, Greedy,
+ Cog, Command, Context,
clean_content, command, group,
)
from bot.bot import Bot
-from bot.cogs.extensions import Extension
-from bot.converters import FetchedMember, TagNameConverter
+from bot.converters import TagNameConverter
from bot.pagination import LinePaginator
log = logging.getLogger(__name__)
@@ -51,56 +50,6 @@ class Alias (Cog):
ctx, embed, empty=False, max_lines=20
)
- @command(name="resources", aliases=("resource",), hidden=True)
- async def site_resources_alias(self, ctx: Context) -> None:
- """Alias for invoking <prefix>site resources."""
- await self.invoke(ctx, "site resources")
-
- @command(name="tools", hidden=True)
- async def site_tools_alias(self, ctx: Context) -> None:
- """Alias for invoking <prefix>site tools."""
- await self.invoke(ctx, "site tools")
-
- @command(name="watch", hidden=True)
- async def bigbrother_watch_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
- """Alias for invoking <prefix>bigbrother watch [user] [reason]."""
- await self.invoke(ctx, "bigbrother watch", user, reason=reason)
-
- @command(name="unwatch", hidden=True)
- async def bigbrother_unwatch_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
- """Alias for invoking <prefix>bigbrother unwatch [user] [reason]."""
- await self.invoke(ctx, "bigbrother unwatch", user, reason=reason)
-
- @command(name="home", hidden=True)
- async def site_home_alias(self, ctx: Context) -> None:
- """Alias for invoking <prefix>site home."""
- await self.invoke(ctx, "site home")
-
- @command(name="faq", hidden=True)
- async def site_faq_alias(self, ctx: Context) -> None:
- """Alias for invoking <prefix>site faq."""
- await self.invoke(ctx, "site faq")
-
- @command(name="rules", aliases=("rule",), hidden=True)
- async def site_rules_alias(self, ctx: Context, rules: Greedy[int], *_: str) -> None:
- """Alias for invoking <prefix>site rules."""
- await self.invoke(ctx, "site rules", *rules)
-
- @command(name="reload", hidden=True)
- async def extensions_reload_alias(self, ctx: Context, *extensions: Extension) -> None:
- """Alias for invoking <prefix>extensions reload [extensions...]."""
- await self.invoke(ctx, "extensions reload", *extensions)
-
- @command(name="defon", hidden=True)
- async def defcon_enable_alias(self, ctx: Context) -> None:
- """Alias for invoking <prefix>defcon enable."""
- await self.invoke(ctx, "defcon enable")
-
- @command(name="defoff", hidden=True)
- async def defcon_disable_alias(self, ctx: Context) -> None:
- """Alias for invoking <prefix>defcon disable."""
- await self.invoke(ctx, "defcon disable")
-
@command(name="exception", hidden=True)
async def tags_get_traceback_alias(self, ctx: Context) -> None:
"""Alias for invoking <prefix>tags get traceback."""
@@ -132,21 +81,6 @@ class Alias (Cog):
"""Alias for invoking <prefix>docs get [symbol]."""
await self.invoke(ctx, "docs get", symbol)
- @command(name="nominate", hidden=True)
- async def nomination_add_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
- """Alias for invoking <prefix>talentpool add [user] [reason]."""
- await self.invoke(ctx, "talentpool add", user, reason=reason)
-
- @command(name="unnominate", hidden=True)
- async def nomination_end_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
- """Alias for invoking <prefix>nomination end [user] [reason]."""
- await self.invoke(ctx, "nomination end", user, reason=reason)
-
- @command(name="nominees", hidden=True)
- async def nominees_alias(self, ctx: Context) -> None:
- """Alias for invoking <prefix>tp watched."""
- await self.invoke(ctx, "talentpool watched")
-
def setup(bot: Bot) -> None:
"""Load the Alias cog."""
diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py
index c76bd2c60..7894ec48f 100644
--- a/bot/cogs/antimalware.py
+++ b/bot/cogs/antimalware.py
@@ -55,6 +55,10 @@ class AntiMalware(Cog):
if not message.attachments or not message.guild:
return
+ # Ignore webhook and bot messages
+ if message.webhook_id or message.author.bot:
+ return
+
# Check if user is staff, if is, return
# Since we only care that roles exist to iterate over, check for the attr rather than a User/Member instance
if hasattr(message.author, "roles") and any(role.id in STAFF_ROLES for role in message.author.roles):
diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py
index bc31cbd95..3ad487d8c 100644
--- a/bot/cogs/antispam.py
+++ b/bot/cogs/antispam.py
@@ -27,14 +27,18 @@ log = logging.getLogger(__name__)
RULE_FUNCTION_MAPPING = {
'attachments': rules.apply_attachments,
'burst': rules.apply_burst,
- 'burst_shared': rules.apply_burst_shared,
+ # burst shared is temporarily disabled due to a bug
+ # 'burst_shared': rules.apply_burst_shared,
'chars': rules.apply_chars,
'discord_emojis': rules.apply_discord_emojis,
'duplicates': rules.apply_duplicates,
'links': rules.apply_links,
'mentions': rules.apply_mentions,
'newlines': rules.apply_newlines,
- 'role_mentions': rules.apply_role_mentions
+ '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/codeblock/cog.py b/bot/cogs/codeblock/cog.py
index c29dcaa4f..5ebc596fb 100644
--- a/bot/cogs/codeblock/cog.py
+++ b/bot/cogs/codeblock/cog.py
@@ -9,6 +9,7 @@ from discord.ext.commands import Cog
from bot import constants
from bot.bot import Bot
from bot.cogs.token_remover import TokenRemover
+from bot.cogs.webhook_remover import WEBHOOK_URL_RE
from bot.utils import has_lines
from bot.utils.channel import is_help_channel
from bot.utils.messages import wait_for_deletion
@@ -127,13 +128,14 @@ class CodeBlockCog(Cog, name="Code Block"):
1. Is not authored by a bot
2. Is in a valid channel
3. Has more than 3 lines
- 4. Has no bot token
+ 4. Has no bot or webhook token
"""
return (
not message.author.bot
and self.is_valid_channel(message.channel)
and has_lines(message.content, constants.CodeBlock.minimum_lines)
and not TokenRemover.find_token_in_message(message)
+ and not WEBHOOK_URL_RE.search(message.content)
)
@Cog.listener()
diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py
index 4c0ad5914..9087ac454 100644
--- a/bot/cogs/defcon.py
+++ b/bot/cogs/defcon.py
@@ -10,7 +10,7 @@ from discord.ext.commands import Cog, Context, group
from bot.bot import Bot
from bot.cogs.moderation import ModLog
-from bot.constants import Channels, Colours, Emojis, Event, Icons, Roles
+from bot.constants import Channels, Colours, Emojis, Event, Icons, MODERATION_ROLES, Roles
from bot.decorators import with_role
log = logging.getLogger(__name__)
@@ -119,7 +119,7 @@ class Defcon(Cog):
)
@group(name='defcon', aliases=('dc',), invoke_without_command=True)
- @with_role(Roles.admins, Roles.owners)
+ @with_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)
@@ -162,8 +162,8 @@ class Defcon(Cog):
self.bot.stats.gauge("defcon.threshold", days)
- @defcon_group.command(name='enable', aliases=('on', 'e'))
- @with_role(Roles.admins, Roles.owners)
+ @defcon_group.command(name='enable', aliases=('on', 'e'), root_aliases=("defon",))
+ @with_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!
@@ -175,8 +175,8 @@ class Defcon(Cog):
await self._defcon_action(ctx, days=0, action=Action.ENABLED)
await self.update_channel_topic()
- @defcon_group.command(name='disable', aliases=('off', 'd'))
- @with_role(Roles.admins, Roles.owners)
+ @defcon_group.command(name='disable', aliases=('off', 'd'), root_aliases=("defoff",))
+ @with_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 +184,7 @@ class Defcon(Cog):
await self.update_channel_topic()
@defcon_group.command(name='status', aliases=('s',))
- @with_role(Roles.admins, Roles.owners)
+ @with_role(*MODERATION_ROLES)
async def status_command(self, ctx: Context) -> None:
"""Check the current status of DEFCON mode."""
embed = Embed(
@@ -196,7 +196,7 @@ class Defcon(Cog):
await ctx.send(embed=embed)
@defcon_group.command(name='days')
- @with_role(Roles.admins, Roles.owners)
+ @with_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/extensions.py b/bot/cogs/extensions.py
index 365f198ff..396e406b0 100644
--- a/bot/cogs/extensions.py
+++ b/bot/cogs/extensions.py
@@ -107,7 +107,7 @@ class Extensions(commands.Cog):
await ctx.send(msg)
- @extensions_group.command(name="reload", aliases=("r",))
+ @extensions_group.command(name="reload", aliases=("r",), root_aliases=("reload",))
async def reload_command(self, ctx: Context, *extensions: Extension) -> None:
r"""
Reload extensions given their fully qualified or unqualified names.
diff --git a/bot/cogs/help.py b/bot/cogs/help.py
index 3d1d6fd10..99d503f5c 100644
--- a/bot/cogs/help.py
+++ b/bot/cogs/help.py
@@ -1,50 +1,28 @@
import itertools
import logging
-from asyncio import TimeoutError
from collections import namedtuple
from contextlib import suppress
from typing import List, Union
-from discord import Colour, Embed, Member, Message, NotFound, Reaction, User
+from discord import Colour, Embed
from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand
from fuzzywuzzy import fuzz, process
from fuzzywuzzy.utils import full_process
from bot import constants
-from bot.constants import Channels, Emojis, STAFF_ROLES
+from bot.constants import Channels, STAFF_ROLES
from bot.decorators import redirect_output
from bot.pagination import LinePaginator
+from bot.utils.messages import wait_for_deletion
log = logging.getLogger(__name__)
COMMANDS_PER_PAGE = 8
-DELETE_EMOJI = Emojis.trashcan
PREFIX = constants.Bot.prefix
Category = namedtuple("Category", ["name", "description", "cogs"])
-async def help_cleanup(bot: Bot, author: Member, message: Message) -> None:
- """
- Runs the cleanup for the help command.
-
- Adds the :trashcan: reaction that, when clicked, will delete the help message.
- After a 300 second timeout, the reaction will be removed.
- """
- def check(reaction: Reaction, user: User) -> bool:
- """Checks the reaction is :trashcan:, the author is original author and messages are the same."""
- return str(reaction) == DELETE_EMOJI and user.id == author.id and reaction.message.id == message.id
-
- await message.add_reaction(DELETE_EMOJI)
-
- with suppress(NotFound):
- try:
- await bot.wait_for("reaction_add", check=check, timeout=300)
- await message.delete()
- except TimeoutError:
- await message.remove_reaction(DELETE_EMOJI, bot.user)
-
-
class HelpQueryNotFound(ValueError):
"""
Raised when a HelpSession Query doesn't match a command or cog.
@@ -189,7 +167,9 @@ class CustomHelpCommand(HelpCommand):
command_details = f"**```{PREFIX}{name} {command.signature}```**\n"
# show command aliases
- aliases = ", ".join(f"`{alias}`" if not parent else f"`{parent} {alias}`" for alias in command.aliases)
+ aliases = [f"`{alias}`" if not parent else f"`{parent} {alias}`" for alias in command.aliases]
+ aliases += [f"`{alias}`" for alias in getattr(command, "root_aliases", ())]
+ aliases = ", ".join(sorted(aliases))
if aliases:
command_details += f"**Can also use:** {aliases}\n\n"
@@ -206,7 +186,7 @@ class CustomHelpCommand(HelpCommand):
"""Send help for a single command."""
embed = await self.command_formatting(command)
message = await self.context.send(embed=embed)
- await help_cleanup(self.context.bot, self.context.author, message)
+ await wait_for_deletion(message, (self.context.author.id,), self.context.bot)
@staticmethod
def get_commands_brief_details(commands_: List[Command], return_as_list: bool = False) -> Union[List[str], str]:
@@ -245,7 +225,7 @@ class CustomHelpCommand(HelpCommand):
embed.description += f"\n**Subcommands:**\n{command_details}"
message = await self.context.send(embed=embed)
- await help_cleanup(self.context.bot, self.context.author, message)
+ await wait_for_deletion(message, (self.context.author.id,), self.context.bot)
async def send_cog_help(self, cog: Cog) -> None:
"""Send help for a cog."""
@@ -261,7 +241,7 @@ class CustomHelpCommand(HelpCommand):
embed.description += f"\n\n**Commands:**\n{command_details}"
message = await self.context.send(embed=embed)
- await help_cleanup(self.context.bot, self.context.author, message)
+ await wait_for_deletion(message, (self.context.author.id,), self.context.bot)
@staticmethod
def _category_key(command: Command) -> str:
diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py
index 97044f1f2..eb577eda4 100644
--- a/bot/cogs/help_channels.py
+++ b/bot/cogs/help_channels.py
@@ -36,7 +36,7 @@ the **Help: Dormant** category.
Try to write the best question you can by providing a detailed description and telling us what \
you've tried already. For more information on asking a good question, \
-check out our guide on [asking good questions]({ASKING_GUIDE_URL}).
+check out our guide on **[asking good questions]({ASKING_GUIDE_URL})**.
"""
DORMANT_MSG = f"""
@@ -47,7 +47,7 @@ channel until it becomes available again.
If your question wasn't answered yet, you can claim a new help channel from the \
**Help: Available** category by simply asking your question again. Consider rephrasing the \
question to maximize your chance of getting a good answer. If you're not sure how, have a look \
-through our guide for [asking a good question]({ASKING_GUIDE_URL}).
+through our guide for **[asking a good question]({ASKING_GUIDE_URL})**.
"""
CoroutineFunc = t.Callable[..., t.Coroutine]
diff --git a/bot/cogs/site.py b/bot/cogs/site.py
index ac29daa1d..2d3a3d9f3 100644
--- a/bot/cogs/site.py
+++ b/bot/cogs/site.py
@@ -23,7 +23,7 @@ class Site(Cog):
"""Commands for getting info about our website."""
await ctx.send_help(ctx.command)
- @site_group.command(name="home", aliases=("about",))
+ @site_group.command(name="home", aliases=("about",), root_aliases=("home",))
async def site_main(self, ctx: Context) -> None:
"""Info about the website itself."""
url = f"{URLs.site_schema}{URLs.site}/"
@@ -40,7 +40,7 @@ class Site(Cog):
await ctx.send(embed=embed)
- @site_group.command(name="resources")
+ @site_group.command(name="resources", root_aliases=("resources", "resource"))
async def site_resources(self, ctx: Context) -> None:
"""Info about the site's Resources page."""
learning_url = f"{PAGES_URL}/resources"
@@ -56,7 +56,7 @@ class Site(Cog):
await ctx.send(embed=embed)
- @site_group.command(name="tools")
+ @site_group.command(name="tools", root_aliases=("tools",))
async def site_tools(self, ctx: Context) -> None:
"""Info about the site's Tools page."""
tools_url = f"{PAGES_URL}/resources/tools"
@@ -87,7 +87,7 @@ class Site(Cog):
await ctx.send(embed=embed)
- @site_group.command(name="faq")
+ @site_group.command(name="faq", root_aliases=("faq",))
async def site_faq(self, ctx: Context) -> None:
"""Info about the site's FAQ page."""
url = f"{PAGES_URL}/frequently-asked-questions"
@@ -104,7 +104,7 @@ class Site(Cog):
await ctx.send(embed=embed)
- @site_group.command(aliases=['r', 'rule'], name='rules')
+ @site_group.command(name="rules", aliases=("r", "rule"), root_aliases=("rules", "rule"))
async def site_rules(self, ctx: Context, *rules: int) -> None:
"""Provides a link to all rules or, if specified, displays specific rule(s)."""
rules_embed = Embed(title='Rules', color=Colour.blurple())
diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py
index 7aa9cec58..11ab8917a 100644
--- a/bot/cogs/watchchannels/bigbrother.py
+++ b/bot/cogs/watchchannels/bigbrother.py
@@ -59,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',))
+ @bigbrother_group.command(name='watch', aliases=('w',), root_aliases=('watch',))
@with_role(*MODERATION_ROLES)
async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
"""
@@ -70,7 +70,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
"""
await self.apply_watch(ctx, user, reason)
- @bigbrother_group.command(name='unwatch', aliases=('uw',))
+ @bigbrother_group.command(name='unwatch', aliases=('uw',), root_aliases=('unwatch',))
@with_role(*MODERATION_ROLES)
async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
"""Stop relaying messages by the given `user`."""
diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py
index a6df84c23..76d6fe9bd 100644
--- a/bot/cogs/watchchannels/talentpool.py
+++ b/bot/cogs/watchchannels/talentpool.py
@@ -37,7 +37,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
"""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'))
+ @nomination_group.command(name='watched', aliases=('all', 'list'), root_aliases=("nominees",))
@with_role(*MODERATION_ROLES)
async def watched_command(
self, ctx: Context, oldest_first: bool = False, update_cache: bool = True
@@ -63,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'))
+ @nomination_group.command(name='watch', aliases=('w', 'add', 'a'), root_aliases=("nominate",))
@with_role(*STAFF_ROLES)
async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
"""
@@ -157,7 +157,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
max_size=1000
)
- @nomination_group.command(name='unwatch', aliases=('end', ))
+ @nomination_group.command(name='unwatch', aliases=('end', ), root_aliases=("unnominate",))
@with_role(*MODERATION_ROLES)
async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
"""
diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py
index 044077350..a58b604c0 100644
--- a/bot/cogs/watchchannels/watchchannel.py
+++ b/bot/cogs/watchchannels/watchchannel.py
@@ -15,6 +15,8 @@ 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.pagination import LinePaginator
from bot.utils import CogABCMeta, messages
@@ -226,14 +228,16 @@ class WatchChannel(metaclass=CogABCMeta):
await self.send_header(msg)
- cleaned_content = msg.clean_content
-
- if cleaned_content:
+ if TokenRemover.find_token_in_message(msg) or WEBHOOK_URL_RE.search(msg.content):
+ cleaned_content = "Content is censored because it contains a bot or webhook token."
+ elif cleaned_content := msg.clean_content:
# Put all non-media URLs in a code block to prevent embeds
media_urls = {embed.url for embed in msg.embeds if embed.type in ("image", "video")}
for url in URL_RE.findall(cleaned_content):
if url not in media_urls:
cleaned_content = cleaned_content.replace(url, f"`{url}`")
+
+ if cleaned_content:
await self.webhook_send(
cleaned_content,
username=msg.author.display_name,
diff --git a/bot/cogs/wolfram.py b/bot/cogs/wolfram.py
deleted file mode 100644
index e6cae3bb8..000000000
--- a/bot/cogs/wolfram.py
+++ /dev/null
@@ -1,280 +0,0 @@
-import logging
-from io import BytesIO
-from typing import Callable, List, Optional, Tuple
-from urllib import parse
-
-import discord
-from dateutil.relativedelta import relativedelta
-from discord import Embed
-from discord.ext import commands
-from discord.ext.commands import BucketType, Cog, Context, check, group
-
-from bot.bot import Bot
-from bot.constants import Colours, STAFF_ROLES, Wolfram
-from bot.pagination import ImagePaginator
-from bot.utils.time import humanize_delta
-
-log = logging.getLogger(__name__)
-
-APPID = Wolfram.key
-DEFAULT_OUTPUT_FORMAT = "JSON"
-QUERY = "http://api.wolframalpha.com/v2/{request}?{data}"
-WOLF_IMAGE = "https://www.symbols.com/gi.php?type=1&id=2886&i=1"
-
-MAX_PODS = 20
-
-# Allows for 10 wolfram calls pr user pr day
-usercd = commands.CooldownMapping.from_cooldown(Wolfram.user_limit_day, 60*60*24, BucketType.user)
-
-# Allows for max api requests / days in month per day for the entire guild (Temporary)
-guildcd = commands.CooldownMapping.from_cooldown(Wolfram.guild_limit_day, 60*60*24, BucketType.guild)
-
-
-async def send_embed(
- ctx: Context,
- message_txt: str,
- colour: int = Colours.soft_red,
- footer: str = None,
- img_url: str = None,
- f: discord.File = None
-) -> None:
- """Generate & send a response embed with Wolfram as the author."""
- embed = Embed(colour=colour)
- embed.description = message_txt
- embed.set_author(name="Wolfram Alpha",
- icon_url=WOLF_IMAGE,
- url="https://www.wolframalpha.com/")
- if footer:
- embed.set_footer(text=footer)
-
- if img_url:
- embed.set_image(url=img_url)
-
- await ctx.send(embed=embed, file=f)
-
-
-def custom_cooldown(*ignore: List[int]) -> Callable:
- """
- Implement per-user and per-guild cooldowns for requests to the Wolfram API.
-
- A list of roles may be provided to ignore the per-user cooldown
- """
- async def predicate(ctx: Context) -> bool:
- if ctx.invoked_with == 'help':
- # if the invoked command is help we don't want to increase the ratelimits since it's not actually
- # invoking the command/making a request, so instead just check if the user/guild are on cooldown.
- guild_cooldown = not guildcd.get_bucket(ctx.message).get_tokens() == 0 # if guild is on cooldown
- if not any(r.id in ignore for r in ctx.author.roles): # check user bucket if user is not ignored
- return guild_cooldown and not usercd.get_bucket(ctx.message).get_tokens() == 0
- return guild_cooldown
-
- user_bucket = usercd.get_bucket(ctx.message)
-
- if all(role.id not in ignore for role in ctx.author.roles):
- user_rate = user_bucket.update_rate_limit()
-
- if user_rate:
- # Can't use api; cause: member limit
- delta = relativedelta(seconds=int(user_rate))
- cooldown = humanize_delta(delta)
- message = (
- "You've used up your limit for Wolfram|Alpha requests.\n"
- f"Cooldown: {cooldown}"
- )
- await send_embed(ctx, message)
- return False
-
- guild_bucket = guildcd.get_bucket(ctx.message)
- guild_rate = guild_bucket.update_rate_limit()
-
- # Repr has a token attribute to read requests left
- log.debug(guild_bucket)
-
- if guild_rate:
- # Can't use api; cause: guild limit
- message = (
- "The max limit of requests for the server has been reached for today.\n"
- f"Cooldown: {int(guild_rate)}"
- )
- await send_embed(ctx, message)
- return False
-
- return True
- return check(predicate)
-
-
-async def get_pod_pages(ctx: Context, bot: Bot, query: str) -> Optional[List[Tuple]]:
- """Get the Wolfram API pod pages for the provided query."""
- async with ctx.channel.typing():
- url_str = parse.urlencode({
- "input": query,
- "appid": APPID,
- "output": DEFAULT_OUTPUT_FORMAT,
- "format": "image,plaintext"
- })
- request_url = QUERY.format(request="query", data=url_str)
-
- async with bot.http_session.get(request_url) as response:
- json = await response.json(content_type='text/plain')
-
- result = json["queryresult"]
-
- if result["error"]:
- # API key not set up correctly
- if result["error"]["msg"] == "Invalid appid":
- message = "Wolfram API key is invalid or missing."
- log.warning(
- "API key seems to be missing, or invalid when "
- f"processing a wolfram request: {url_str}, Response: {json}"
- )
- await send_embed(ctx, message)
- return
-
- message = "Something went wrong internally with your request, please notify staff!"
- log.warning(f"Something went wrong getting a response from wolfram: {url_str}, Response: {json}")
- await send_embed(ctx, message)
- return
-
- if not result["success"]:
- message = f"I couldn't find anything for {query}."
- await send_embed(ctx, message)
- return
-
- if not result["numpods"]:
- message = "Could not find any results."
- await send_embed(ctx, message)
- return
-
- pods = result["pods"]
- pages = []
- for pod in pods[:MAX_PODS]:
- subs = pod.get("subpods")
-
- for sub in subs:
- title = sub.get("title") or sub.get("plaintext") or sub.get("id", "")
- img = sub["img"]["src"]
- pages.append((title, img))
- return pages
-
-
-class Wolfram(Cog):
- """Commands for interacting with the Wolfram|Alpha API."""
-
- def __init__(self, bot: Bot):
- self.bot = bot
-
- @group(name="wolfram", aliases=("wolf", "wa"), invoke_without_command=True)
- @custom_cooldown(*STAFF_ROLES)
- async def wolfram_command(self, ctx: Context, *, query: str) -> None:
- """Requests all answers on a single image, sends an image of all related pods."""
- url_str = parse.urlencode({
- "i": query,
- "appid": APPID,
- })
- query = QUERY.format(request="simple", data=url_str)
-
- # Give feedback that the bot is working.
- async with ctx.channel.typing():
- async with self.bot.http_session.get(query) as response:
- status = response.status
- image_bytes = await response.read()
-
- f = discord.File(BytesIO(image_bytes), filename="image.png")
- image_url = "attachment://image.png"
-
- if status == 501:
- message = "Failed to get response"
- footer = ""
- color = Colours.soft_red
- elif status == 400:
- message = "No input found"
- footer = ""
- color = Colours.soft_red
- elif status == 403:
- message = "Wolfram API key is invalid or missing."
- footer = ""
- color = Colours.soft_red
- else:
- message = ""
- footer = "View original for a bigger picture."
- color = Colours.soft_orange
-
- # Sends a "blank" embed if no request is received, unsure how to fix
- await send_embed(ctx, message, color, footer=footer, img_url=image_url, f=f)
-
- @wolfram_command.command(name="page", aliases=("pa", "p"))
- @custom_cooldown(*STAFF_ROLES)
- async def wolfram_page_command(self, ctx: Context, *, query: str) -> None:
- """
- Requests a drawn image of given query.
-
- Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc.
- """
- pages = await get_pod_pages(ctx, self.bot, query)
-
- if not pages:
- return
-
- embed = Embed()
- embed.set_author(name="Wolfram Alpha",
- icon_url=WOLF_IMAGE,
- url="https://www.wolframalpha.com/")
- embed.colour = Colours.soft_orange
-
- await ImagePaginator.paginate(pages, ctx, embed)
-
- @wolfram_command.command(name="cut", aliases=("c",))
- @custom_cooldown(*STAFF_ROLES)
- async def wolfram_cut_command(self, ctx: Context, *, query: str) -> None:
- """
- Requests a drawn image of given query.
-
- Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc.
- """
- pages = await get_pod_pages(ctx, self.bot, query)
-
- if not pages:
- return
-
- if len(pages) >= 2:
- page = pages[1]
- else:
- page = pages[0]
-
- await send_embed(ctx, page[0], colour=Colours.soft_orange, img_url=page[1])
-
- @wolfram_command.command(name="short", aliases=("sh", "s"))
- @custom_cooldown(*STAFF_ROLES)
- async def wolfram_short_command(self, ctx: Context, *, query: str) -> None:
- """Requests an answer to a simple question."""
- url_str = parse.urlencode({
- "i": query,
- "appid": APPID,
- })
- query = QUERY.format(request="result", data=url_str)
-
- # Give feedback that the bot is working.
- async with ctx.channel.typing():
- async with self.bot.http_session.get(query) as response:
- status = response.status
- response_text = await response.text()
-
- if status == 501:
- message = "Failed to get response"
- color = Colours.soft_red
- elif status == 400:
- message = "No input found"
- color = Colours.soft_red
- elif response_text == "Error 1: Invalid appid":
- message = "Wolfram API key is invalid or missing."
- color = Colours.soft_red
- else:
- message = response_text
- color = Colours.soft_orange
-
- await send_embed(ctx, message, color)
-
-
-def setup(bot: Bot) -> None:
- """Load the Wolfram cog."""
- bot.add_cog(Wolfram(bot))
diff --git a/bot/command.py b/bot/command.py
new file mode 100644
index 000000000..0fb900f7b
--- /dev/null
+++ b/bot/command.py
@@ -0,0 +1,18 @@
+from discord.ext import commands
+
+
+class Command(commands.Command):
+ """
+ A `discord.ext.commands.Command` subclass which supports root aliases.
+
+ A `root_aliases` keyword argument is added, which is a sequence of alias names that will act as
+ top-level commands rather than being aliases of the command's group. It's stored as an attribute
+ also named `root_aliases`.
+ """
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.root_aliases = kwargs.get("root_aliases", [])
+
+ if not isinstance(self.root_aliases, (list, tuple)):
+ raise TypeError("Root aliases of a command must be a list or a tuple of strings.")
diff --git a/bot/constants.py b/bot/constants.py
index a28588427..43f3e10bc 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -514,14 +514,6 @@ class Reddit(metaclass=YAMLGetter):
secret: Optional[str]
-class Wolfram(metaclass=YAMLGetter):
- section = "wolfram"
-
- user_limit_day: int
- guild_limit_day: int
- key: Optional[str]
-
-
class AntiSpam(metaclass=YAMLGetter):
section = 'anti_spam'
diff --git a/bot/pagination.py b/bot/pagination.py
index bab98cacf..182b2fa76 100644
--- a/bot/pagination.py
+++ b/bot/pagination.py
@@ -374,167 +374,3 @@ class LinePaginator(Paginator):
log.debug("Ending pagination and clearing reactions.")
with suppress(discord.NotFound):
await message.clear_reactions()
-
-
-class ImagePaginator(Paginator):
- """
- Helper class that paginates images for embeds in messages.
-
- Close resemblance to LinePaginator, except focuses on images over text.
-
- Refer to ImagePaginator.paginate for documentation on how to use.
- """
-
- def __init__(self, prefix: str = "", suffix: str = ""):
- super().__init__(prefix, suffix)
- self._current_page = [prefix]
- self.images = []
- self._pages = []
- self._count = 0
-
- def add_line(self, line: str = '', *, empty: bool = False) -> None:
- """Adds a line to each page."""
- if line:
- self._count = len(line)
- else:
- self._count = 0
- self._current_page.append(line)
- self.close_page()
-
- def add_image(self, image: str = None) -> None:
- """Adds an image to a page."""
- self.images.append(image)
-
- @classmethod
- async def paginate(
- cls,
- pages: t.List[t.Tuple[str, str]],
- ctx: Context, embed: discord.Embed,
- prefix: str = "",
- suffix: str = "",
- timeout: int = 300,
- exception_on_empty_embed: bool = False
- ) -> t.Optional[discord.Message]:
- """
- Use a paginator and set of reactions to provide pagination over a set of title/image pairs.
-
- The reactions are used to switch page, or to finish with pagination.
-
- When used, this will send a message using `ctx.send()` and apply a set of reactions to it. These reactions may
- be used to change page, or to remove pagination from the message.
-
- Note: Pagination will be removed automatically if no reaction is added for five minutes (300 seconds).
-
- Example:
- >>> embed = discord.Embed()
- >>> embed.set_author(name="Some Operation", url=url, icon_url=icon)
- >>> await ImagePaginator.paginate(pages, ctx, embed)
- """
- def check_event(reaction_: discord.Reaction, member: discord.Member) -> bool:
- """Checks each reaction added, if it matches our conditions pass the wait_for."""
- return all((
- # Reaction is on the same message sent
- reaction_.message.id == message.id,
- # The reaction is part of the navigation menu
- str(reaction_.emoji) in PAGINATION_EMOJI,
- # The reactor is not a bot
- not member.bot
- ))
-
- paginator = cls(prefix=prefix, suffix=suffix)
- current_page = 0
-
- if not pages:
- if exception_on_empty_embed:
- log.exception("Pagination asked for empty image list")
- raise EmptyPaginatorEmbed("No images to paginate")
-
- log.debug("No images to add to paginator, adding '(no images to display)' message")
- pages.append(("(no images to display)", ""))
-
- for text, image_url in pages:
- paginator.add_line(text)
- paginator.add_image(image_url)
-
- embed.description = paginator.pages[current_page]
- image = paginator.images[current_page]
-
- if image:
- embed.set_image(url=image)
-
- if len(paginator.pages) <= 1:
- return await ctx.send(embed=embed)
-
- embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}")
- message = await ctx.send(embed=embed)
-
- for emoji in PAGINATION_EMOJI:
- await message.add_reaction(emoji)
-
- while True:
- # Start waiting for reactions
- try:
- reaction, user = await ctx.bot.wait_for("reaction_add", timeout=timeout, check=check_event)
- except asyncio.TimeoutError:
- log.debug("Timed out waiting for a reaction")
- break # We're done, no reactions for the last 5 minutes
-
- # Deletes the users reaction
- await message.remove_reaction(reaction.emoji, user)
-
- # Delete reaction press - [:trashcan:]
- if str(reaction.emoji) == DELETE_EMOJI:
- log.debug("Got delete reaction")
- return await message.delete()
-
- # First reaction press - [:track_previous:]
- if reaction.emoji == FIRST_EMOJI:
- if current_page == 0:
- log.debug("Got first page reaction, but we're on the first page - ignoring")
- continue
-
- current_page = 0
- reaction_type = "first"
-
- # Last reaction press - [:track_next:]
- if reaction.emoji == LAST_EMOJI:
- if current_page >= len(paginator.pages) - 1:
- log.debug("Got last page reaction, but we're on the last page - ignoring")
- continue
-
- current_page = len(paginator.pages) - 1
- reaction_type = "last"
-
- # Previous reaction press - [:arrow_left: ]
- if reaction.emoji == LEFT_EMOJI:
- if current_page <= 0:
- log.debug("Got previous page reaction, but we're on the first page - ignoring")
- continue
-
- current_page -= 1
- reaction_type = "previous"
-
- # Next reaction press - [:arrow_right:]
- if reaction.emoji == RIGHT_EMOJI:
- if current_page >= len(paginator.pages) - 1:
- log.debug("Got next page reaction, but we're on the last page - ignoring")
- continue
-
- current_page += 1
- reaction_type = "next"
-
- # Magic happens here, after page and reaction_type is set
- embed.description = paginator.pages[current_page]
-
- image = paginator.images[current_page]
- if image:
- embed.set_image(url=image)
-
- embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}")
- log.debug(f"Got {reaction_type} page reaction - changing to page {current_page + 1}/{len(paginator.pages)}")
-
- await message.edit(embed=embed)
-
- log.debug("Ending pagination and clearing reactions.")
- with suppress(discord.NotFound):
- await message.clear_reactions()
diff --git a/bot/rules/__init__.py b/bot/rules/__init__.py
index a01ceae73..8a69cadee 100644
--- a/bot/rules/__init__.py
+++ b/bot/rules/__init__.py
@@ -10,3 +10,4 @@ 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/discord_emojis.py b/bot/rules/discord_emojis.py
index 5bab514f2..6e47f0197 100644
--- a/bot/rules/discord_emojis.py
+++ b/bot/rules/discord_emojis.py
@@ -5,6 +5,7 @@ from discord import Member, Message
DISCORD_EMOJI_RE = re.compile(r"<:\w+:\d+>")
+CODE_BLOCK_RE = re.compile(r"```.*?```", flags=re.DOTALL)
async def apply(
@@ -17,8 +18,9 @@ async def apply(
if msg.author == last_message.author
)
+ # Get rid of code blocks in the message before searching for emojis.
total_emojis = sum(
- len(DISCORD_EMOJI_RE.findall(msg.content))
+ len(DISCORD_EMOJI_RE.findall(CODE_BLOCK_RE.sub("", msg.content)))
for msg in relevant_messages
)
diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py
new file mode 100644
index 000000000..89d9fe570
--- /dev/null
+++ b/bot/rules/everyone_ping.py
@@ -0,0 +1,41 @@
+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/config-default.yml b/config-default.yml
index 324e39346..c5df37509 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -39,7 +39,7 @@ style:
status_offline: "<:status_offline:470326266537705472>"
badge_staff: "<:discord_staff:743882896498098226>"
- badge_partner: "<:partner:743882897131569323>"
+ badge_partner: "<:partner:748666453242413136>"
badge_hypesquad: "<:hypesquad_events:743882896892362873>"
badge_bug_hunter: "<:bug_hunter_lvl1:743882896372269137>"
badge_hypesquad_bravery: "<:hypesquad_bravery:743882896745693335>"
@@ -352,9 +352,13 @@ anti_spam:
interval: 10
max: 7
- burst_shared:
- interval: 10
- max: 20
+ # Burst shared it (temporarily) disabled to prevent
+ # the bug that triggers multiple infractions/DMs per
+ # user. It also tends to catch a lot of innocent users
+ # now that we're so big.
+ # burst_shared:
+ # interval: 10
+ # max: 20
chars:
interval: 5
@@ -385,6 +389,12 @@ 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:
@@ -393,13 +403,6 @@ reddit:
secret: !ENV "REDDIT_SECRET"
-wolfram:
- # Max requests per day.
- user_limit_day: 10
- guild_limit_day: 67
- key: !ENV "WOLFRAM_API_KEY"
-
-
big_brother:
log_delay: 15
header_message_limit: 15
diff --git a/tests/bot/cogs/test_antimalware.py b/tests/bot/cogs/test_antimalware.py
index ecb7abf00..f50c0492d 100644
--- a/tests/bot/cogs/test_antimalware.py
+++ b/tests/bot/cogs/test_antimalware.py
@@ -23,6 +23,8 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase):
}
self.cog = antimalware.AntiMalware(self.bot)
self.message = MockMessage()
+ self.message.webhook_id = None
+ self.message.author.bot = None
self.whitelist = [".first", ".second", ".third"]
async def test_message_with_allowed_attachment(self):
@@ -48,6 +50,26 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase):
self.message.delete.assert_not_called()
+ async def test_webhook_message_with_illegal_extension(self):
+ """A webhook message containing an illegal extension should be ignored."""
+ attachment = MockAttachment(filename="python.disallowed")
+ self.message.webhook_id = 697140105563078727
+ self.message.attachments = [attachment]
+
+ await self.cog.on_message(self.message)
+
+ self.message.delete.assert_not_called()
+
+ async def test_bot_message_with_illegal_extension(self):
+ """A bot message containing an illegal extension should be ignored."""
+ attachment = MockAttachment(filename="python.disallowed")
+ self.message.author.bot = 409107086526644234
+ self.message.attachments = [attachment]
+
+ await self.cog.on_message(self.message)
+
+ self.message.delete.assert_not_called()
+
async def test_message_with_illegal_extension_gets_deleted(self):
"""A message containing an illegal extension should send an embed."""
attachment = MockAttachment(filename="python.disallowed")
diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py
index fdda59a8f..30a04422a 100644
--- a/tests/bot/cogs/test_cogs.py
+++ b/tests/bot/cogs/test_cogs.py
@@ -53,6 +53,7 @@ class CommandNameTests(unittest.TestCase):
"""Return a list of all qualified names, including aliases, for the `command`."""
names = [f"{command.full_parent_name} {alias}".strip() for alias in command.aliases]
names.append(command.qualified_name)
+ names += getattr(command, "root_aliases", [])
return names
diff --git a/tests/bot/test_pagination.py b/tests/bot/test_pagination.py
index ce880d457..630f2516d 100644
--- a/tests/bot/test_pagination.py
+++ b/tests/bot/test_pagination.py
@@ -44,18 +44,3 @@ class LinePaginatorTests(TestCase):
self.paginator.add_line('x' * (self.paginator.scale_to_size + 1))
# Note: item at index 1 is the truncated line, index 0 is prefix
self.assertEqual(self.paginator._current_page[1], 'x' * self.paginator.scale_to_size)
-
-
-class ImagePaginatorTests(TestCase):
- """Tests functionality of the `ImagePaginator`."""
-
- def setUp(self):
- """Create a paginator for the test method."""
- self.paginator = pagination.ImagePaginator()
-
- def test_add_image_appends_image(self):
- """`add_image` appends the image to the image list."""
- image = 'lemon'
- self.paginator.add_image(image)
-
- assert self.paginator.images == [image]