aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--bot/cogs/antimalware.py55
-rw-r--r--bot/cogs/clean.py75
-rw-r--r--bot/cogs/help_channels.py39
-rw-r--r--bot/cogs/information.py65
-rw-r--r--bot/cogs/moderation/infractions.py7
-rw-r--r--bot/cogs/moderation/modlog.py5
-rw-r--r--bot/cogs/moderation/scheduler.py36
-rw-r--r--bot/cogs/moderation/superstarify.py2
-rw-r--r--bot/cogs/moderation/utils.py13
-rw-r--r--bot/cogs/python_news.py1
-rw-r--r--bot/cogs/sync/cog.py4
-rw-r--r--bot/cogs/sync/syncers.py3
-rw-r--r--bot/cogs/tags.py2
-rw-r--r--bot/cogs/verification.py43
-rw-r--r--bot/cogs/watchchannels/bigbrother.py5
-rw-r--r--bot/cogs/watchchannels/talentpool.py10
-rw-r--r--bot/cogs/watchchannels/watchchannel.py3
-rw-r--r--bot/resources/tags/free.md4
-rw-r--r--bot/resources/tags/modmail.md9
-rw-r--r--tests/bot/cogs/moderation/test_infractions.py55
-rw-r--r--tests/bot/cogs/moderation/test_modlog.py29
-rw-r--r--tests/bot/cogs/sync/test_cog.py3
-rw-r--r--tests/bot/cogs/sync/test_users.py2
-rw-r--r--tests/bot/cogs/test_antimalware.py159
-rw-r--r--tests/bot/cogs/test_information.py12
-rw-r--r--tests/helpers.py4
26 files changed, 460 insertions, 185 deletions
diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py
index 66b5073e8..ea257442e 100644
--- a/bot/cogs/antimalware.py
+++ b/bot/cogs/antimalware.py
@@ -1,4 +1,5 @@
import logging
+import typing as t
from os.path import splitext
from discord import Embed, Message, NotFound
@@ -9,6 +10,27 @@ from bot.constants import AntiMalware as AntiMalwareConfig, Channels, STAFF_ROLE
log = logging.getLogger(__name__)
+PY_EMBED_DESCRIPTION = (
+ "It looks like you tried to attach a Python file - "
+ f"please use a code-pasting service such as {URLs.site_schema}{URLs.site_paste}"
+)
+
+TXT_EMBED_DESCRIPTION = (
+ "**Uh-oh!** It looks like your message got zapped by our spam filter. "
+ "We currently don't allow `.txt` attachments, so here are some tips to help you travel safely: \n\n"
+ "• If you attempted to send a message longer than 2000 characters, try shortening your message "
+ "to fit within the character limit or use a pasting service (see below) \n\n"
+ "• If you tried to show someone your code, you can use codeblocks \n(run `!code-blocks` in "
+ "{cmd_channel_mention} for more information) or use a pasting service like: "
+ f"\n\n{URLs.site_schema}{URLs.site_paste}"
+)
+
+DISALLOWED_EMBED_DESCRIPTION = (
+ "It looks like you tried to attach file type(s) that we do not allow ({blocked_extensions_str}). "
+ f"We currently allow the following file types: **{', '.join(AntiMalwareConfig.whitelist)}**.\n\n"
+ "Feel free to ask in {meta_channel_mention} if you think this is a mistake."
+)
+
class AntiMalware(Cog):
"""Delete messages which contain attachments with non-whitelisted file extensions."""
@@ -29,34 +51,20 @@ class AntiMalware(Cog):
return
embed = Embed()
- file_extensions = {splitext(attachment.filename.lower())[1] for attachment in message.attachments}
- extensions_blocked = file_extensions - set(AntiMalwareConfig.whitelist)
+ extensions_blocked = self.get_disallowed_extensions(message)
blocked_extensions_str = ', '.join(extensions_blocked)
if ".py" in extensions_blocked:
# Short-circuit on *.py files to provide a pastebin link
- embed.description = (
- "It looks like you tried to attach a Python file - "
- f"please use a code-pasting service such as {URLs.site_schema}{URLs.site_paste}"
- )
+ embed.description = PY_EMBED_DESCRIPTION
elif ".txt" in extensions_blocked:
# Work around Discord AutoConversion of messages longer than 2000 chars to .txt
cmd_channel = self.bot.get_channel(Channels.bot_commands)
- embed.description = (
- "**Uh-oh!** It looks like your message got zapped by our spam filter. "
- "We currently don't allow `.txt` attachments, so here are some tips to help you travel safely: \n\n"
- "• If you attempted to send a message longer than 2000 characters, try shortening your message "
- "to fit within the character limit or use a pasting service (see below) \n\n"
- "• If you tried to show someone your code, you can use codeblocks \n(run `!code-blocks` in "
- f"{cmd_channel.mention} for more information) or use a pasting service like: "
- f"\n\n{URLs.site_schema}{URLs.site_paste}"
- )
+ embed.description = TXT_EMBED_DESCRIPTION.format(cmd_channel_mention=cmd_channel.mention)
elif extensions_blocked:
- whitelisted_types = ', '.join(AntiMalwareConfig.whitelist)
meta_channel = self.bot.get_channel(Channels.meta)
- embed.description = (
- f"It looks like you tried to attach file type(s) that we do not allow ({blocked_extensions_str}). "
- f"We currently allow the following file types: **{whitelisted_types}**.\n\n"
- f"Feel free to ask in {meta_channel.mention} if you think this is a mistake."
+ embed.description = DISALLOWED_EMBED_DESCRIPTION.format(
+ blocked_extensions_str=blocked_extensions_str,
+ meta_channel_mention=meta_channel.mention,
)
if embed.description:
@@ -73,6 +81,13 @@ class AntiMalware(Cog):
except NotFound:
log.info(f"Tried to delete message `{message.id}`, but message could not be found.")
+ @classmethod
+ def get_disallowed_extensions(cls, message: Message) -> t.Iterable[str]:
+ """Get an iterable containing all the disallowed extensions of attachments."""
+ file_extensions = {splitext(attachment.filename.lower())[1] for attachment in message.attachments}
+ extensions_blocked = file_extensions - set(AntiMalwareConfig.whitelist)
+ return extensions_blocked
+
def setup(bot: Bot) -> None:
"""Load the AntiMalware cog."""
diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py
index b5d9132cb..368d91c85 100644
--- a/bot/cogs/clean.py
+++ b/bot/cogs/clean.py
@@ -1,16 +1,16 @@
import logging
import random
import re
-from typing import Optional
+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 bot.bot import Bot
from bot.cogs.moderation import ModLog
from bot.constants import (
- Channels, CleanMessages, Colours, Event,
- Icons, MODERATION_ROLES, NEGATIVE_REPLIES
+ Channels, CleanMessages, Colours, Event, Icons, MODERATION_ROLES, NEGATIVE_REPLIES
)
from bot.decorators import with_role
@@ -41,10 +41,10 @@ class Clean(Cog):
self,
amount: int,
ctx: Context,
+ channels: Iterable[TextChannel],
bots_only: bool = False,
user: User = None,
regex: Optional[str] = None,
- channel: Optional[TextChannel] = None
) -> None:
"""A helper function that does the actual message cleaning."""
def predicate_bots_only(message: Message) -> bool:
@@ -110,48 +110,39 @@ class Clean(Cog):
predicate = None # Delete all messages
# Default to using the invoking context's channel
- if not channel:
- channel = ctx.channel
+ if not channels:
+ channels = [ctx.channel]
+
+ # Delete the invocation first
+ self.mod_log.ignore(Event.message_delete, ctx.message.id)
+ await ctx.message.delete()
- # Look through the history and retrieve message data
messages = []
message_ids = []
self.cleaning = True
- invocation_deleted = False
-
- # To account for the invocation message, we index `amount + 1` messages.
- async for message in channel.history(limit=amount + 1):
- # If at any point the cancel command is invoked, we should stop.
- if not self.cleaning:
- return
+ # Find the IDs of the messages to delete. IDs are needed in order to ignore mod log events.
+ for channel in channels:
+ async for message in channel.history(limit=amount):
- # Always start by deleting the invocation
- if not invocation_deleted:
- self.mod_log.ignore(Event.message_delete, message.id)
- await message.delete()
- invocation_deleted = True
- continue
+ # If at any point the cancel command is invoked, we should stop.
+ if not self.cleaning:
+ return
- # If the message passes predicate, let's save it.
- if predicate is None or predicate(message):
- message_ids.append(message.id)
- messages.append(message)
+ # If the message passes predicate, let's save it.
+ if predicate is None or predicate(message):
+ message_ids.append(message.id)
self.cleaning = False
- # We should ignore the ID's we stored, so we don't get mod-log spam.
+ # Now let's delete the actual messages with purge.
self.mod_log.ignore(Event.message_delete, *message_ids)
-
- # Use bulk delete to actually do the cleaning. It's far faster.
- await channel.purge(
- limit=amount,
- check=predicate
- )
+ for channel in channels:
+ messages += await channel.purge(limit=amount, check=predicate)
# Reverse the list to restore chronological order
if messages:
- messages = list(reversed(messages))
+ messages = reversed(messages)
log_url = await self.mod_log.upload_log(messages, ctx.author.id)
else:
# Can't build an embed, nothing to clean!
@@ -163,8 +154,10 @@ class Clean(Cog):
return
# Build the embed and send it
+ target_channels = ", ".join(channel.mention for channel in channels)
+
message = (
- f"**{len(message_ids)}** messages deleted in <#{channel.id}> by **{ctx.author.name}**\n\n"
+ f"**{len(message_ids)}** messages deleted in {target_channels} by **{ctx.author.name}**\n\n"
f"A log of the deleted messages can be found [here]({log_url})."
)
@@ -189,10 +182,10 @@ class Clean(Cog):
ctx: Context,
user: User,
amount: Optional[int] = 10,
- channel: TextChannel = None
+ channels: commands.Greedy[TextChannel] = None
) -> None:
"""Delete messages posted by the provided user, stop cleaning after traversing `amount` messages."""
- await self._clean_messages(amount, ctx, user=user, channel=channel)
+ await self._clean_messages(amount, ctx, user=user, channels=channels)
@clean_group.command(name="all", aliases=["everything"])
@with_role(*MODERATION_ROLES)
@@ -200,10 +193,10 @@ class Clean(Cog):
self,
ctx: Context,
amount: Optional[int] = 10,
- channel: TextChannel = None
+ channels: commands.Greedy[TextChannel] = None
) -> None:
"""Delete all messages, regardless of poster, stop cleaning after traversing `amount` messages."""
- await self._clean_messages(amount, ctx, channel=channel)
+ await self._clean_messages(amount, ctx, channels=channels)
@clean_group.command(name="bots", aliases=["bot"])
@with_role(*MODERATION_ROLES)
@@ -211,10 +204,10 @@ class Clean(Cog):
self,
ctx: Context,
amount: Optional[int] = 10,
- channel: TextChannel = None
+ channels: commands.Greedy[TextChannel] = None
) -> None:
"""Delete all messages posted by a bot, stop cleaning after traversing `amount` messages."""
- await self._clean_messages(amount, ctx, bots_only=True, channel=channel)
+ await self._clean_messages(amount, ctx, bots_only=True, channels=channels)
@clean_group.command(name="regex", aliases=["word", "expression"])
@with_role(*MODERATION_ROLES)
@@ -223,10 +216,10 @@ class Clean(Cog):
ctx: Context,
regex: str,
amount: Optional[int] = 10,
- channel: TextChannel = None
+ channels: commands.Greedy[TextChannel] = None
) -> None:
"""Delete all messages that match a certain regex, stop cleaning after traversing `amount` messages."""
- await self._clean_messages(amount, ctx, regex=regex, channel=channel)
+ await self._clean_messages(amount, ctx, regex=regex, channels=channels)
@clean_group.command(name="stop", aliases=["cancel", "abort"])
@with_role(*MODERATION_ROLES)
diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py
index d2a55fba6..70cef339a 100644
--- a/bot/cogs/help_channels.py
+++ b/bot/cogs/help_channels.py
@@ -24,18 +24,8 @@ ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/"
MAX_CHANNELS_PER_CATEGORY = 50
EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help,)
-AVAILABLE_TOPIC = """
-This channel is available. Feel free to ask a question in order to claim this channel!
-"""
-
-IN_USE_TOPIC = """
-This channel is currently in use. If you'd like to discuss a different problem, please claim a new \
-channel from the Help: Available category.
-"""
-
-DORMANT_TOPIC = """
-This channel is temporarily archived. If you'd like to ask a question, please use one of the \
-channels in the Help: Available category.
+HELP_CHANNEL_TOPIC = """
+This is a Python help channel. You can claim your own help channel in the Python Help: Available category.
"""
AVAILABLE_MSG = f"""
@@ -64,11 +54,6 @@ question to maximize your chance of getting a good answer. If you're not sure ho
through our guide for [asking a good question]({ASKING_GUIDE_URL}).
"""
-AVAILABLE_EMOJI = "✅"
-IN_USE_ANSWERED_EMOJI = "⌛"
-IN_USE_UNANSWERED_EMOJI = "⏳"
-NAME_SEPARATOR = "|"
-
CoroutineFunc = t.Callable[..., t.Coroutine]
@@ -196,7 +181,7 @@ class HelpChannels(Scheduler, commands.Cog):
return None
log.debug(f"Creating a new dormant channel named {name}.")
- return await self.dormant_category.create_text_channel(name)
+ return await self.dormant_category.create_text_channel(name, topic=HELP_CHANNEL_TOPIC)
def create_name_queue(self) -> deque:
"""Return a queue of element names to use for creating new channels."""
@@ -542,8 +527,6 @@ class HelpChannels(Scheduler, commands.Cog):
await self.move_to_bottom_position(
channel=channel,
category_id=constants.Categories.help_available,
- name=f"{AVAILABLE_EMOJI}{NAME_SEPARATOR}{self.get_clean_channel_name(channel)}",
- topic=AVAILABLE_TOPIC,
)
self.report_stats()
@@ -559,8 +542,6 @@ class HelpChannels(Scheduler, commands.Cog):
await self.move_to_bottom_position(
channel=channel,
category_id=constants.Categories.help_dormant,
- name=self.get_clean_channel_name(channel),
- topic=DORMANT_TOPIC,
)
self.bot.stats.incr(f"help.dormant_calls.{caller}")
@@ -593,8 +574,6 @@ class HelpChannels(Scheduler, commands.Cog):
await self.move_to_bottom_position(
channel=channel,
category_id=constants.Categories.help_in_use,
- name=f"{IN_USE_UNANSWERED_EMOJI}{NAME_SEPARATOR}{self.get_clean_channel_name(channel)}",
- topic=IN_USE_TOPIC,
)
timeout = constants.HelpChannels.idle_minutes * 60
@@ -660,18 +639,16 @@ class HelpChannels(Scheduler, commands.Cog):
# Check if there is an entry in unanswered (does not persist across restarts)
if channel.id in self.unanswered:
- claimant_id = self.help_channel_claimants[channel].id
+ claimant = self.help_channel_claimants.get(channel)
+ if not claimant:
+ # The mapping for this channel was lost, we can't do anything.
+ return
# Check the message did not come from the claimant
- if claimant_id != message.author.id:
+ if claimant.id != message.author.id:
# Mark the channel as answered
self.unanswered[channel.id] = False
- # Change the emoji in the channel name to signify activity
- log.trace(f"#{channel} ({channel.id}) has been answered; changing its emoji")
- name = self.get_clean_channel_name(channel)
- await channel.edit(name=f"{IN_USE_ANSWERED_EMOJI}{NAME_SEPARATOR}{name}")
-
@commands.Cog.listener()
async def on_message(self, message: discord.Message) -> None:
"""Move an available channel to the In Use category and replace it with a dormant one."""
diff --git a/bot/cogs/information.py b/bot/cogs/information.py
index f0eb3a1ea..f0bd1afdb 100644
--- a/bot/cogs/information.py
+++ b/bot/cogs/information.py
@@ -6,7 +6,8 @@ from collections import Counter, defaultdict
from string import Template
from typing import Any, Mapping, Optional, Union
-from discord import Colour, Embed, Member, Message, Role, Status, utils
+from discord import ChannelType, Colour, 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.utils import escape_markdown
@@ -26,6 +27,49 @@ class Information(Cog):
def __init__(self, bot: Bot):
self.bot = bot
+ @staticmethod
+ def role_can_read(channel: GuildChannel, role: Role) -> bool:
+ """Return True if `role` can read messages in `channel`."""
+ overwrites = channel.overwrites_for(role)
+ return overwrites.read_messages is True
+
+ def get_staff_channel_count(self, guild: Guild) -> int:
+ """
+ Get the number of channels that are staff-only.
+
+ We need to know two things about a channel:
+ - Does the @everyone role have explicit read deny permissions?
+ - Do staff roles have explicit read allow permissions?
+
+ If the answer to both of these questions is yes, it's a staff channel.
+ """
+ channel_ids = set()
+ for channel in guild.channels:
+ if channel.type is ChannelType.category:
+ continue
+
+ everyone_can_read = self.role_can_read(channel, guild.default_role)
+
+ for role in constants.STAFF_ROLES:
+ role_can_read = self.role_can_read(channel, guild.get_role(role))
+ if role_can_read and not everyone_can_read:
+ channel_ids.add(channel.id)
+ break
+
+ return len(channel_ids)
+
+ @staticmethod
+ def get_channel_type_counts(guild: Guild) -> str:
+ """Return the total amounts of the various types of channels in `guild`."""
+ channel_counter = Counter(c.type for c in guild.channels)
+ channel_type_list = []
+ for channel, count in channel_counter.items():
+ channel_type = str(channel).title()
+ channel_type_list.append(f"{channel_type} channels: {count}")
+
+ channel_type_list = sorted(channel_type_list)
+ return "\n".join(channel_type_list)
+
@with_role(*constants.MODERATION_ROLES)
@command(name="roles")
async def roles_info(self, ctx: Context) -> None:
@@ -102,15 +146,16 @@ class Information(Cog):
roles = len(ctx.guild.roles)
member_count = ctx.guild.member_count
-
- # How many of each type of channel?
- channels = Counter(c.type for c in ctx.guild.channels)
- channel_counts = "".join(sorted(f"{str(ch).title()} channels: {channels[ch]}\n" for ch in channels)).strip()
+ channel_counts = self.get_channel_type_counts(ctx.guild)
# How many of each user status?
statuses = Counter(member.status for member in ctx.guild.members)
embed = Embed(colour=Colour.blurple())
+ # How many staff members and staff channels do we have?
+ staff_member_count = len(ctx.guild.get_role(constants.Roles.helpers).members)
+ staff_channel_count = self.get_staff_channel_count(ctx.guild)
+
# Because channel_counts lacks leading whitespace, it breaks the dedent if it's inserted directly by the
# f-string. While this is correctly formated by Discord, it makes unit testing difficult. To keep the formatting
# without joining a tuple of strings we can use a Template string to insert the already-formatted channel_counts
@@ -122,12 +167,16 @@ class Information(Cog):
Voice region: {region}
Features: {features}
- **Counts**
+ **Channel counts**
+ $channel_counts
+ Staff channels: {staff_channel_count}
+
+ **Member counts**
Members: {member_count:,}
+ Staff members: {staff_member_count}
Roles: {roles}
- $channel_counts
- **Members**
+ **Member statuses**
{constants.Emojis.status_online} {statuses[Status.online]:,}
{constants.Emojis.status_idle} {statuses[Status.idle]:,}
{constants.Emojis.status_dnd} {statuses[Status.dnd]:,}
diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py
index e62a36c43..5bfaad796 100644
--- a/bot/cogs/moderation/infractions.py
+++ b/bot/cogs/moderation/infractions.py
@@ -1,4 +1,5 @@
import logging
+import textwrap
import typing as t
import discord
@@ -225,7 +226,7 @@ class Infractions(InfractionScheduler, commands.Cog):
self.mod_log.ignore(Event.member_remove, user.id)
- action = user.kick(reason=reason)
+ action = user.kick(reason=textwrap.shorten(reason, width=512, placeholder="..."))
await self.apply_infraction(ctx, infraction, user, action)
@respect_role_hierarchy()
@@ -258,7 +259,9 @@ class Infractions(InfractionScheduler, commands.Cog):
self.mod_log.ignore(Event.member_remove, user.id)
- action = ctx.guild.ban(user, reason=reason, delete_message_days=0)
+ truncated_reason = textwrap.shorten(reason, width=512, placeholder="...")
+
+ action = ctx.guild.ban(user, reason=truncated_reason, delete_message_days=0)
await self.apply_infraction(ctx, infraction, user, action)
if infraction.get('expires_at') is not None:
diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py
index beef7a8ef..9d28030d9 100644
--- a/bot/cogs/moderation/modlog.py
+++ b/bot/cogs/moderation/modlog.py
@@ -98,7 +98,10 @@ class ModLog(Cog, name="ModLog"):
footer: t.Optional[str] = None,
) -> Context:
"""Generate log embed and send to logging channel."""
- embed = discord.Embed(description=text)
+ # Truncate string directly here to avoid removing newlines
+ embed = discord.Embed(
+ description=text[:2045] + "..." if len(text) > 2048 else text
+ )
if title and icon_url:
embed.set_author(name=title, icon_url=icon_url)
diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py
index 012432e60..b03d89537 100644
--- a/bot/cogs/moderation/scheduler.py
+++ b/bot/cogs/moderation/scheduler.py
@@ -101,11 +101,16 @@ class InfractionScheduler(Scheduler):
dm_result = ""
dm_log_text = ""
- expiry_log_text = f"Expires: {expiry}" if expiry else ""
+ expiry_log_text = f"\nExpires: {expiry}" if expiry else ""
log_title = "applied"
log_content = None
+ failed = False
# DM the user about the infraction if it's not a shadow/hidden infraction.
+ # This needs to happen before we apply the infraction, as the bot cannot
+ # send DMs to user that it doesn't share a guild with. If we were to
+ # apply kick/ban infractions first, this would mean that we'd make it
+ # impossible for us to deliver a DM. See python-discord/bot#982.
if not infraction["hidden"]:
dm_result = f"{constants.Emojis.failmail} "
dm_log_text = "\nDM: **Failed**"
@@ -127,7 +132,7 @@ class InfractionScheduler(Scheduler):
f"Infraction #{id_} actor is bot; including the reason in the confirmation message."
)
- end_msg = f" (reason: {infraction['reason']})"
+ end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})"
elif ctx.channel.id not in STAFF_CHANNELS:
log.trace(
f"Infraction #{id_} context is not in a staff channel; omitting infraction count."
@@ -164,12 +169,23 @@ class InfractionScheduler(Scheduler):
log.warning(f"{log_msg}: bot lacks permissions.")
else:
log.exception(log_msg)
+ failed = True
+
+ if failed:
+ log.trace(f"Deleted infraction {infraction['id']} from database because applying infraction failed.")
+ try:
+ await self.bot.api_client.delete(f"bot/infractions/{id_}")
+ except ResponseCodeError as e:
+ confirm_msg += " and failed to delete"
+ log_title += " and failed to delete"
+ log.error(f"Deletion of {infr_type} infraction #{id_} failed with error code {e.status}.")
+ infr_message = ""
+ else:
+ infr_message = f" **{infr_type}** to {user.mention}{expiry_msg}{end_msg}"
# Send a confirmation message to the invoking context.
log.trace(f"Sending infraction #{id_} confirmation message.")
- await ctx.send(
- f"{dm_result}{confirm_msg} **{infr_type}** to {user.mention}{expiry_msg}{end_msg}."
- )
+ await ctx.send(f"{dm_result}{confirm_msg}{infr_message}.")
# Send a log message to the mod log.
log.trace(f"Sending apply mod log for infraction #{id_}.")
@@ -180,9 +196,8 @@ class InfractionScheduler(Scheduler):
thumbnail=user.avatar_url_as(static_format="png"),
text=textwrap.dedent(f"""
Member: {user.mention} (`{user.id}`)
- Actor: {ctx.message.author}{dm_log_text}
+ Actor: {ctx.message.author}{dm_log_text}{expiry_log_text}
Reason: {reason}
- {expiry_log_text}
"""),
content=log_content,
footer=f"ID {infraction['id']}"
@@ -294,6 +309,9 @@ class InfractionScheduler(Scheduler):
f"{log_text.get('Failure', '')}"
)
+ # Move reason to end of entry to avoid cutting out some keys
+ log_text["Reason"] = log_text.pop("Reason")
+
# Send a log message to the mod log.
await self.mod_log.send_log_message(
icon_url=utils.INFRACTION_ICONS[infr_type][1],
@@ -407,6 +425,9 @@ class InfractionScheduler(Scheduler):
user = self.bot.get_user(user_id)
avatar = user.avatar_url_as(static_format="png") if user else None
+ # Move reason to end so when reason is too long, this is not gonna cut out required items.
+ log_text["Reason"] = log_text.pop("Reason")
+
log.trace(f"Sending deactivation mod log for infraction #{id_}.")
await self.mod_log.send_log_message(
icon_url=utils.INFRACTION_ICONS[type_][1],
@@ -416,7 +437,6 @@ class InfractionScheduler(Scheduler):
text="\n".join(f"{k}: {v}" for k, v in log_text.items()),
footer=f"ID: {id_}",
content=log_content,
-
)
return log_text
diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py
index 29855c325..45a010f00 100644
--- a/bot/cogs/moderation/superstarify.py
+++ b/bot/cogs/moderation/superstarify.py
@@ -183,10 +183,10 @@ class Superstarify(InfractionScheduler, Cog):
text=textwrap.dedent(f"""
Member: {member.mention} (`{member.id}`)
Actor: {ctx.message.author}
- Reason: {reason}
Expires: {expiry_str}
Old nickname: `{old_nick}`
New nickname: `{forced_nick}`
+ Reason: {reason}
"""),
footer=f"ID {id_}"
)
diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py
index e4e0f1ec2..fb55287b6 100644
--- a/bot/cogs/moderation/utils.py
+++ b/bot/cogs/moderation/utils.py
@@ -41,7 +41,6 @@ async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]:
log.debug("The user being added to the DB is not a Member or User object.")
payload = {
- 'avatar_hash': getattr(user, 'avatar', 0),
'discriminator': int(getattr(user, 'discriminator', 0)),
'id': user.id,
'in_guild': False,
@@ -143,12 +142,14 @@ 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."}
+ """)
+
embed = discord.Embed(
- description=textwrap.dedent(f"""
- **Type:** {infr_type.capitalize()}
- **Expires:** {expires_at or "N/A"}
- **Reason:** {reason or "No reason provided."}
- """),
+ description=textwrap.shorten(text, width=2048, placeholder="..."),
colour=Colours.soft_red
)
diff --git a/bot/cogs/python_news.py b/bot/cogs/python_news.py
index d28af4a0b..d15d0371e 100644
--- a/bot/cogs/python_news.py
+++ b/bot/cogs/python_news.py
@@ -153,6 +153,7 @@ class PythonNews(Cog):
if (
thread_information["thread_id"] in existing_news["data"][maillist]
+ or 'Re: ' in thread_information["subject"]
or new_date.date() < date.today()
):
continue
diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py
index 5708be3f4..7cc3726b2 100644
--- a/bot/cogs/sync/cog.py
+++ b/bot/cogs/sync/cog.py
@@ -94,7 +94,6 @@ class Sync(Cog):
the database, the user is added.
"""
packed = {
- 'avatar_hash': member.avatar,
'discriminator': int(member.discriminator),
'id': member.id,
'in_guild': True,
@@ -135,12 +134,11 @@ class Sync(Cog):
@Cog.listener()
async def on_user_update(self, before: User, after: User) -> None:
"""Update the user information in the database if a relevant change is detected."""
- attrs = ("name", "discriminator", "avatar")
+ attrs = ("name", "discriminator")
if any(getattr(before, attr) != getattr(after, attr) for attr in attrs):
updated_information = {
"name": after.name,
"discriminator": int(after.discriminator),
- "avatar_hash": after.avatar,
}
await self.patch_user(after.id, updated_information=updated_information)
diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py
index e55bf27fd..536455668 100644
--- a/bot/cogs/sync/syncers.py
+++ b/bot/cogs/sync/syncers.py
@@ -17,7 +17,7 @@ log = logging.getLogger(__name__)
# These objects are declared as namedtuples because tuples are hashable,
# something that we make use of when diffing site roles against guild roles.
_Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions', 'position'))
-_User = namedtuple('User', ('id', 'name', 'discriminator', 'avatar_hash', 'roles', 'in_guild'))
+_User = namedtuple('User', ('id', 'name', 'discriminator', 'roles', 'in_guild'))
_Diff = namedtuple('Diff', ('created', 'updated', 'deleted'))
@@ -298,7 +298,6 @@ class UserSyncer(Syncer):
id=member.id,
name=member.name,
discriminator=int(member.discriminator),
- avatar_hash=member.avatar,
roles=tuple(sorted(role.id for role in member.roles)),
in_guild=True
)
diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py
index bc7f53f68..6f03a3475 100644
--- a/bot/cogs/tags.py
+++ b/bot/cogs/tags.py
@@ -44,7 +44,7 @@ class Tags(Cog):
tag = {
"title": tag_title,
"embed": {
- "description": file.read_text(),
+ "description": file.read_text(encoding="utf8"),
},
"restricted_to": "developers",
}
diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py
index 99be3cdaa..ae156cf70 100644
--- a/bot/cogs/verification.py
+++ b/bot/cogs/verification.py
@@ -1,9 +1,7 @@
import logging
from contextlib import suppress
-from datetime import datetime
from discord import Colour, Forbidden, Message, NotFound, Object
-from discord.ext import tasks
from discord.ext.commands import Cog, Context, command
from bot import constants
@@ -34,14 +32,6 @@ If you'd like to unsubscribe from the announcement notifications, simply send `!
<#{constants.Channels.bot_commands}>.
"""
-if constants.DEBUG_MODE:
- PERIODIC_PING = "Periodic checkpoint message successfully sent."
-else:
- PERIODIC_PING = (
- f"@everyone To verify that you have read our rules, please type `{constants.Bot.prefix}accept`."
- " If you encounter any problems during the verification process, "
- f"send a direct message to a staff member."
- )
BOT_MESSAGE_DELETE_DELAY = 10
@@ -50,7 +40,6 @@ class Verification(Cog):
def __init__(self, bot: Bot):
self.bot = bot
- self.periodic_ping.start()
@property
def mod_log(self) -> ModLog:
@@ -65,9 +54,7 @@ class Verification(Cog):
if message.author.bot:
# They're a bot, delete their message after the delay.
- # But not the periodic ping; we like that one.
- if message.content != PERIODIC_PING:
- await message.delete(delay=BOT_MESSAGE_DELETE_DELAY)
+ await message.delete(delay=BOT_MESSAGE_DELETE_DELAY)
return
# if a user mentions a role or guild member
@@ -198,34 +185,6 @@ class Verification(Cog):
else:
return True
- @tasks.loop(hours=12)
- async def periodic_ping(self) -> None:
- """Every week, mention @everyone to remind them to verify."""
- messages = self.bot.get_channel(constants.Channels.verification).history(limit=10)
- need_to_post = True # True if a new message needs to be sent.
-
- async for message in messages:
- if message.author == self.bot.user and message.content == PERIODIC_PING:
- delta = datetime.utcnow() - message.created_at # Time since last message.
- if delta.days >= 7: # Message is older than a week.
- await message.delete()
- else:
- need_to_post = False
-
- break
-
- if need_to_post:
- await self.bot.get_channel(constants.Channels.verification).send(PERIODIC_PING)
-
- @periodic_ping.before_loop
- async def before_ping(self) -> None:
- """Only start the loop when the bot is ready."""
- await self.bot.wait_until_guild_available()
-
- def cog_unload(self) -> None:
- """Cancel the periodic ping task when the cog is unloaded."""
- self.periodic_ping.cancel()
-
def setup(bot: Bot) -> None:
"""Load the Verification cog."""
diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py
index e4fb173e0..702d371f4 100644
--- a/bot/cogs/watchchannels/bigbrother.py
+++ b/bot/cogs/watchchannels/bigbrother.py
@@ -1,4 +1,5 @@
import logging
+import textwrap
from collections import ChainMap
from discord.ext.commands import Cog, Context, group
@@ -97,8 +98,8 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
if len(history) > 1:
total = f"({len(history) // 2} previous infractions in total)"
- end_reason = history[0]["reason"]
- start_reason = f"Watched: {history[1]['reason']}"
+ end_reason = textwrap.shorten(history[0]["reason"], width=500, placeholder="...")
+ start_reason = f"Watched: {textwrap.shorten(history[1]['reason'], width=500, placeholder='...')}"
msg += f"\n\nUser's previous watch reasons {total}:```{start_reason}\n\n{end_reason}```"
else:
msg = ":x: Failed to post the infraction: response was empty."
diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py
index cd9c7e555..14547105f 100644
--- a/bot/cogs/watchchannels/talentpool.py
+++ b/bot/cogs/watchchannels/talentpool.py
@@ -106,8 +106,8 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
if history:
total = f"({len(history)} previous nominations in total)"
- start_reason = f"Watched: {history[0]['reason']}"
- end_reason = f"Unwatched: {history[0]['end_reason']}"
+ start_reason = f"Watched: {textwrap.shorten(history[0]['reason'], width=500, placeholder='...')}"
+ end_reason = f"Unwatched: {textwrap.shorten(history[0]['end_reason'], width=500, placeholder='...')}"
msg += f"\n\nUser's previous watch reasons {total}:```{start_reason}\n\n{end_reason}```"
await ctx.send(msg)
@@ -224,7 +224,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
Status: **Active**
Date: {start_date}
Actor: {actor.mention if actor else actor_id}
- Reason: {nomination_object["reason"]}
+ Reason: {textwrap.shorten(nomination_object["reason"], width=200, placeholder="...")}
Nomination ID: `{nomination_object["id"]}`
===============
"""
@@ -237,10 +237,10 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
Status: Inactive
Date: {start_date}
Actor: {actor.mention if actor else actor_id}
- Reason: {nomination_object["reason"]}
+ Reason: {textwrap.shorten(nomination_object["reason"], width=200, placeholder="...")}
End date: {end_date}
- Unwatch reason: {nomination_object["end_reason"]}
+ Unwatch reason: {textwrap.shorten(nomination_object["end_reason"], width=200, placeholder="...")}
Nomination ID: `{nomination_object["id"]}`
===============
"""
diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py
index 643cd46e4..436778c46 100644
--- a/bot/cogs/watchchannels/watchchannel.py
+++ b/bot/cogs/watchchannels/watchchannel.py
@@ -280,8 +280,9 @@ class WatchChannel(metaclass=CogABCMeta):
else:
message_jump = f"in [#{msg.channel.name}]({msg.jump_url})"
+ footer = f"Added {time_delta} by {actor} | Reason: {reason}"
embed = Embed(description=f"{msg.author.mention} {message_jump}")
- embed.set_footer(text=f"Added {time_delta} by {actor} | Reason: {reason}")
+ embed.set_footer(text=textwrap.shorten(footer, width=128, placeholder="..."))
await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.avatar_url)
diff --git a/bot/resources/tags/free.md b/bot/resources/tags/free.md
index 582cca9da..1493076c7 100644
--- a/bot/resources/tags/free.md
+++ b/bot/resources/tags/free.md
@@ -1,5 +1,5 @@
**We have a new help channel system!**
-We recently moved to a new help channel system. You can now use any channel in the **<#691405807388196926>** category to ask your question.
+Please see <#704250143020417084> for further information.
-For more information, check out [our website](https://pythondiscord.com/pages/resources/guides/help-channels/).
+A more detailed guide can be found on [our website](https://pythondiscord.com/pages/resources/guides/help-channels/).
diff --git a/bot/resources/tags/modmail.md b/bot/resources/tags/modmail.md
new file mode 100644
index 000000000..7545419ee
--- /dev/null
+++ b/bot/resources/tags/modmail.md
@@ -0,0 +1,9 @@
+**Contacting the moderation team via ModMail**
+
+<@!683001325440860340> is a bot that will relay your messages to our moderation team, so that you can start a conversation with the moderation team. Your messages will be relayed to the entire moderator team, who will be able to respond to you via the bot.
+
+It supports attachments, codeblocks, and reactions. As communication happens over direct messages, the conversation will stay between you and the mod team.
+
+**To use it, simply send a direct message to the bot.**
+
+Should there be an urgent and immediate need for a moderator or admin to look at a channel, feel free to ping the <@&267629731250176001> or <@&267628507062992896> role instead.
diff --git a/tests/bot/cogs/moderation/test_infractions.py b/tests/bot/cogs/moderation/test_infractions.py
new file mode 100644
index 000000000..da4e92ccc
--- /dev/null
+++ b/tests/bot/cogs/moderation/test_infractions.py
@@ -0,0 +1,55 @@
+import textwrap
+import unittest
+from unittest.mock import AsyncMock, Mock, patch
+
+from bot.cogs.moderation.infractions import Infractions
+from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole
+
+
+class TruncationTests(unittest.IsolatedAsyncioTestCase):
+ """Tests for ban and kick command reason truncation."""
+
+ def setUp(self):
+ self.bot = MockBot()
+ self.cog = Infractions(self.bot)
+ self.user = MockMember(id=1234, top_role=MockRole(id=3577, position=10))
+ self.target = MockMember(id=1265, top_role=MockRole(id=9876, position=0))
+ 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")
+ 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
+ post_infraction_mock.return_value = {"foo": "bar"}
+
+ self.cog.apply_infraction = AsyncMock()
+ self.bot.get_cog.return_value = AsyncMock()
+ self.cog.mod_log.ignore = Mock()
+ self.ctx.guild.ban = Mock()
+
+ await self.cog.apply_ban(self.ctx, self.target, "foo bar" * 3000)
+ self.ctx.guild.ban.assert_called_once_with(
+ self.target,
+ reason=textwrap.shorten("foo bar" * 3000, 512, placeholder="..."),
+ delete_message_days=0
+ )
+ self.cog.apply_infraction.assert_awaited_once_with(
+ self.ctx, {"foo": "bar"}, self.target, self.ctx.guild.ban.return_value
+ )
+
+ @patch("bot.cogs.moderation.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"}
+
+ self.cog.apply_infraction = AsyncMock()
+ self.cog.mod_log.ignore = Mock()
+ self.target.kick = Mock()
+
+ await self.cog.apply_kick(self.ctx, self.target, "foo bar" * 3000)
+ self.target.kick.assert_called_once_with(reason=textwrap.shorten("foo bar" * 3000, 512, placeholder="..."))
+ self.cog.apply_infraction.assert_awaited_once_with(
+ self.ctx, {"foo": "bar"}, self.target, self.target.kick.return_value
+ )
diff --git a/tests/bot/cogs/moderation/test_modlog.py b/tests/bot/cogs/moderation/test_modlog.py
new file mode 100644
index 000000000..f2809f40a
--- /dev/null
+++ b/tests/bot/cogs/moderation/test_modlog.py
@@ -0,0 +1,29 @@
+import unittest
+
+import discord
+
+from bot.cogs.moderation.modlog import ModLog
+from tests.helpers import MockBot, MockTextChannel
+
+
+class ModLogTests(unittest.IsolatedAsyncioTestCase):
+ """Tests for moderation logs."""
+
+ def setUp(self):
+ self.bot = MockBot()
+ self.cog = ModLog(self.bot)
+ self.channel = MockTextChannel()
+
+ async def test_log_entry_description_truncation(self):
+ """Test that embed description for ModLog entry is truncated."""
+ self.bot.get_channel.return_value = self.channel
+ await self.cog.send_log_message(
+ icon_url="foo",
+ colour=discord.Colour.blue(),
+ title="bar",
+ text="foo bar" * 3000
+ )
+ embed = self.channel.send.call_args[1]["embed"]
+ self.assertEqual(
+ embed.description, ("foo bar" * 3000)[:2045] + "..."
+ )
diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py
index 81398c61f..14fd909c4 100644
--- a/tests/bot/cogs/sync/test_cog.py
+++ b/tests/bot/cogs/sync/test_cog.py
@@ -247,14 +247,12 @@ class SyncCogListenerTests(SyncCogTestCase):
before_data = {
"name": "old name",
"discriminator": "1234",
- "avatar": "old avatar",
"bot": False,
}
subtests = (
(True, "name", "name", "new name", "new name"),
(True, "discriminator", "discriminator", "8765", 8765),
- (True, "avatar", "avatar_hash", "9j2e9", "9j2e9"),
(False, "bot", "bot", True, True),
)
@@ -295,7 +293,6 @@ class SyncCogListenerTests(SyncCogTestCase):
)
data = {
- "avatar_hash": member.avatar,
"discriminator": int(member.discriminator),
"id": member.id,
"in_guild": True,
diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py
index 818883012..002a947ad 100644
--- a/tests/bot/cogs/sync/test_users.py
+++ b/tests/bot/cogs/sync/test_users.py
@@ -10,7 +10,6 @@ def fake_user(**kwargs):
kwargs.setdefault("id", 43)
kwargs.setdefault("name", "bob the test man")
kwargs.setdefault("discriminator", 1337)
- kwargs.setdefault("avatar_hash", None)
kwargs.setdefault("roles", (666,))
kwargs.setdefault("in_guild", True)
@@ -32,7 +31,6 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase):
for member in members:
member = member.copy()
- member["avatar"] = member.pop("avatar_hash")
del member["in_guild"]
mock_member = helpers.MockMember(**member)
diff --git a/tests/bot/cogs/test_antimalware.py b/tests/bot/cogs/test_antimalware.py
new file mode 100644
index 000000000..f219fc1ba
--- /dev/null
+++ b/tests/bot/cogs/test_antimalware.py
@@ -0,0 +1,159 @@
+import unittest
+from unittest.mock import AsyncMock, Mock, patch
+
+from discord import NotFound
+
+from bot.cogs import antimalware
+from bot.constants import AntiMalware as AntiMalwareConfig, Channels, STAFF_ROLES
+from tests.helpers import MockAttachment, MockBot, MockMessage, MockRole
+
+MODULE = "bot.cogs.antimalware"
+
+
+@patch(f"{MODULE}.AntiMalwareConfig.whitelist", new=[".first", ".second", ".third"])
+class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase):
+ """Test the AntiMalware cog."""
+
+ def setUp(self):
+ """Sets up fresh objects for each test."""
+ self.bot = MockBot()
+ self.cog = antimalware.AntiMalware(self.bot)
+ self.message = MockMessage()
+
+ async def test_message_with_allowed_attachment(self):
+ """Messages with allowed extensions should not be deleted"""
+ attachment = MockAttachment(filename=f"python{AntiMalwareConfig.whitelist[0]}")
+ self.message.attachments = [attachment]
+
+ await self.cog.on_message(self.message)
+ self.message.delete.assert_not_called()
+
+ async def test_message_without_attachment(self):
+ """Messages without attachments should result in no action."""
+ await self.cog.on_message(self.message)
+ self.message.delete.assert_not_called()
+
+ async def test_direct_message_with_attachment(self):
+ """Direct messages should have no action taken."""
+ attachment = MockAttachment(filename="python.disallowed")
+ self.message.attachments = [attachment]
+ self.message.guild = None
+
+ 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")
+ self.message.attachments = [attachment]
+
+ await self.cog.on_message(self.message)
+
+ self.message.delete.assert_called_once()
+
+ async def test_message_send_by_staff(self):
+ """A message send by a member of staff should be ignored."""
+ staff_role = MockRole(id=STAFF_ROLES[0])
+ self.message.author.roles.append(staff_role)
+ attachment = MockAttachment(filename="python.disallowed")
+ self.message.attachments = [attachment]
+
+ await self.cog.on_message(self.message)
+
+ self.message.delete.assert_not_called()
+
+ async def test_python_file_redirect_embed_description(self):
+ """A message containing a .py file should result in an embed redirecting the user to our paste site"""
+ attachment = MockAttachment(filename="python.py")
+ self.message.attachments = [attachment]
+ self.message.channel.send = AsyncMock()
+
+ await self.cog.on_message(self.message)
+ self.message.channel.send.assert_called_once()
+ args, kwargs = self.message.channel.send.call_args
+ embed = kwargs.pop("embed")
+
+ self.assertEqual(embed.description, antimalware.PY_EMBED_DESCRIPTION)
+
+ async def test_txt_file_redirect_embed_description(self):
+ """A message containing a .txt file should result in the correct embed."""
+ attachment = MockAttachment(filename="python.txt")
+ self.message.attachments = [attachment]
+ self.message.channel.send = AsyncMock()
+ antimalware.TXT_EMBED_DESCRIPTION = Mock()
+ antimalware.TXT_EMBED_DESCRIPTION.format.return_value = "test"
+
+ await self.cog.on_message(self.message)
+ self.message.channel.send.assert_called_once()
+ args, kwargs = self.message.channel.send.call_args
+ embed = kwargs.pop("embed")
+ cmd_channel = self.bot.get_channel(Channels.bot_commands)
+
+ self.assertEqual(embed.description, antimalware.TXT_EMBED_DESCRIPTION.format.return_value)
+ antimalware.TXT_EMBED_DESCRIPTION.format.assert_called_with(cmd_channel_mention=cmd_channel.mention)
+
+ async def test_other_disallowed_extention_embed_description(self):
+ """Test the description for a non .py/.txt disallowed extension."""
+ attachment = MockAttachment(filename="python.disallowed")
+ self.message.attachments = [attachment]
+ self.message.channel.send = AsyncMock()
+ antimalware.DISALLOWED_EMBED_DESCRIPTION = Mock()
+ antimalware.DISALLOWED_EMBED_DESCRIPTION.format.return_value = "test"
+
+ await self.cog.on_message(self.message)
+ self.message.channel.send.assert_called_once()
+ args, kwargs = self.message.channel.send.call_args
+ embed = kwargs.pop("embed")
+ meta_channel = self.bot.get_channel(Channels.meta)
+
+ self.assertEqual(embed.description, antimalware.DISALLOWED_EMBED_DESCRIPTION.format.return_value)
+ antimalware.DISALLOWED_EMBED_DESCRIPTION.format.assert_called_with(
+ blocked_extensions_str=".disallowed",
+ meta_channel_mention=meta_channel.mention
+ )
+
+ async def test_removing_deleted_message_logs(self):
+ """Removing an already deleted message logs the correct message"""
+ attachment = MockAttachment(filename="python.disallowed")
+ self.message.attachments = [attachment]
+ self.message.delete = AsyncMock(side_effect=NotFound(response=Mock(status=""), message=""))
+
+ with self.assertLogs(logger=antimalware.log, level="INFO"):
+ await self.cog.on_message(self.message)
+ self.message.delete.assert_called_once()
+
+ async def test_message_with_illegal_attachment_logs(self):
+ """Deleting a message with an illegal attachment should result in a log."""
+ attachment = MockAttachment(filename="python.disallowed")
+ self.message.attachments = [attachment]
+
+ with self.assertLogs(logger=antimalware.log, level="INFO"):
+ await self.cog.on_message(self.message)
+
+ async def test_get_disallowed_extensions(self):
+ """The return value should include all non-whitelisted extensions."""
+ test_values = (
+ ([], []),
+ (AntiMalwareConfig.whitelist, []),
+ ([".first"], []),
+ ([".first", ".disallowed"], [".disallowed"]),
+ ([".disallowed"], [".disallowed"]),
+ ([".disallowed", ".illegal"], [".disallowed", ".illegal"]),
+ )
+
+ for extensions, expected_disallowed_extensions in test_values:
+ with self.subTest(extensions=extensions, expected_disallowed_extensions=expected_disallowed_extensions):
+ self.message.attachments = [MockAttachment(filename=f"filename{extension}") for extension in extensions]
+ disallowed_extensions = self.cog.get_disallowed_extensions(self.message)
+ self.assertCountEqual(disallowed_extensions, expected_disallowed_extensions)
+
+
+class AntiMalwareSetupTests(unittest.TestCase):
+ """Tests setup of the `AntiMalware` cog."""
+
+ def test_setup(self):
+ """Setup of the extension should call add_cog."""
+ bot = MockBot()
+ antimalware.setup(bot)
+ bot.add_cog.assert_called_once()
diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py
index aca6b594f..79c0e0ad3 100644
--- a/tests/bot/cogs/test_information.py
+++ b/tests/bot/cogs/test_information.py
@@ -148,14 +148,18 @@ class InformationCogTests(unittest.TestCase):
Voice region: {self.ctx.guild.region}
Features: {', '.join(self.ctx.guild.features)}
- **Counts**
- Members: {self.ctx.guild.member_count:,}
- Roles: {len(self.ctx.guild.roles)}
+ **Channel counts**
Category channels: 1
Text channels: 1
Voice channels: 1
+ Staff channels: 0
+
+ **Member counts**
+ Members: {self.ctx.guild.member_count:,}
+ Staff members: 0
+ Roles: {len(self.ctx.guild.roles)}
- **Members**
+ **Member statuses**
{constants.Emojis.status_online} 2
{constants.Emojis.status_idle} 1
{constants.Emojis.status_dnd} 4
diff --git a/tests/helpers.py b/tests/helpers.py
index 13283339b..faa839370 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -208,6 +208,10 @@ class MockRole(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin):
"""Simplified position-based comparisons similar to those of `discord.Role`."""
return self.position < other.position
+ def __ge__(self, other):
+ """Simplified position-based comparisons similar to those of `discord.Role`."""
+ return self.position >= other.position
+
# Create a Member instance to get a realistic Mock of `discord.Member`
member_data = {'user': 'lemon', 'roles': [1]}