aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar dementati <[email protected]>2021-12-02 17:08:29 +0100
committerGravatar GitHub <[email protected]>2021-12-02 17:08:29 +0100
commita9a9e7bcc5d5cce0707335f979b635fc91a1e9c7 (patch)
treeabf08e303017f872f68e8670605a815b63aad68b
parentMerge branch 'main' into feature/1903/fix-pin-inconsistency (diff)
parentDynamic views for command help embeds (#1939) (diff)
Merge branch 'main' into feature/1903/fix-pin-inconsistency
-rw-r--r--bot/constants.py19
-rw-r--r--bot/exts/backend/error_handler.py13
-rw-r--r--bot/exts/help_channels/_cog.py36
-rw-r--r--bot/exts/info/help.py147
-rw-r--r--bot/exts/info/information.py12
-rw-r--r--bot/exts/info/pypi.py2
-rw-r--r--bot/exts/info/site.py5
-rw-r--r--bot/exts/info/subscribe.py202
-rw-r--r--bot/exts/moderation/clean.py3
-rw-r--r--bot/exts/moderation/incidents.py263
-rw-r--r--bot/exts/moderation/infraction/_utils.py4
-rw-r--r--bot/exts/moderation/infraction/management.py60
-rw-r--r--bot/exts/moderation/modlog.py52
-rw-r--r--bot/exts/moderation/verification.py71
-rw-r--r--bot/exts/moderation/voice_gate.py6
-rw-r--r--bot/exts/recruitment/talentpool/_cog.py14
-rw-r--r--bot/exts/recruitment/talentpool/_review.py53
-rw-r--r--bot/exts/utils/reminders.py18
-rw-r--r--bot/log.py15
-rw-r--r--bot/resources/tags/off-topic.md4
-rw-r--r--bot/utils/members.py23
-rw-r--r--config-default.yml9
-rw-r--r--docker-compose.yml1
-rw-r--r--tests/bot/exts/backend/test_error_handler.py32
-rw-r--r--tests/bot/exts/moderation/test_incidents.py94
25 files changed, 930 insertions, 228 deletions
diff --git a/bot/constants.py b/bot/constants.py
index e3846fb3d..3170c2915 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -476,6 +476,7 @@ class Webhooks(metaclass=YAMLGetter):
big_brother: int
dev_log: int
duck_pond: int
+ incidents: int
incidents_archive: int
@@ -483,7 +484,12 @@ class Roles(metaclass=YAMLGetter):
section = "guild"
subsection = "roles"
+ # Self-assignable roles, see the Subscribe cog
+ advent_of_code: int
announcements: int
+ lovefest: int
+ pyweek_announcements: int
+
contributors: int
help_cooldown: int
muted: int
@@ -682,8 +688,21 @@ class VideoPermission(metaclass=YAMLGetter):
default_permission_duration: int
+class ThreadArchiveTimes(Enum):
+ HOUR = 60
+ DAY = 1440
+ THREE_DAY = 4230
+ WEEK = 10080
+
+
# Debug mode
DEBUG_MODE: bool = _CONFIG_YAML["debug"] == "true"
+FILE_LOGS: bool = _CONFIG_YAML["file_logs"].lower() == "true"
+
+if DEBUG_MODE:
+ DEFAULT_THREAD_ARCHIVE_TIME = ThreadArchiveTimes.HOUR.value
+else:
+ DEFAULT_THREAD_ARCHIVE_TIME = ThreadArchiveTimes.WEEK.value
# Paths
BOT_DIR = os.path.dirname(__file__)
diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py
index 6ab6634a6..5bef72808 100644
--- a/bot/exts/backend/error_handler.py
+++ b/bot/exts/backend/error_handler.py
@@ -1,5 +1,4 @@
import difflib
-import typing as t
from discord import Embed
from discord.ext.commands import ChannelNotFound, Cog, Context, TextChannelConverter, VoiceChannelConverter, errors
@@ -97,13 +96,14 @@ class ErrorHandler(Cog):
# MaxConcurrencyReached, ExtensionError
await self.handle_unexpected_error(ctx, e)
- @staticmethod
- def get_help_command(ctx: Context) -> t.Coroutine:
+ async def send_command_help(self, ctx: Context) -> None:
"""Return a prepared `help` command invocation coroutine."""
if ctx.command:
- return ctx.send_help(ctx.command)
+ self.bot.help_command.context = ctx
+ await ctx.send_help(ctx.command)
+ return
- return ctx.send_help()
+ await ctx.send_help()
async def try_silence(self, ctx: Context) -> bool:
"""
@@ -245,7 +245,6 @@ class ErrorHandler(Cog):
elif isinstance(e, errors.ArgumentParsingError):
embed = self._get_error_embed("Argument parsing error", str(e))
await ctx.send(embed=embed)
- self.get_help_command(ctx).close()
self.bot.stats.incr("errors.argument_parsing_error")
return
else:
@@ -256,7 +255,7 @@ class ErrorHandler(Cog):
self.bot.stats.incr("errors.other_user_input_error")
await ctx.send(embed=embed)
- await self.get_help_command(ctx)
+ await self.send_command_help(ctx)
@staticmethod
async def handle_check_failure(ctx: Context, e: errors.CheckFailure) -> None:
diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py
index 0905cb23d..60209ba6e 100644
--- a/bot/exts/help_channels/_cog.py
+++ b/bot/exts/help_channels/_cog.py
@@ -66,6 +66,9 @@ class HelpChannels(commands.Cog):
self.bot = bot
self.scheduler = scheduling.Scheduler(self.__class__.__name__)
+ self.guild: discord.Guild = None
+ self.cooldown_role: discord.Role = None
+
# Categories
self.available_category: discord.CategoryChannel = None
self.in_use_category: discord.CategoryChannel = None
@@ -95,24 +98,6 @@ class HelpChannels(commands.Cog):
self.scheduler.cancel_all()
- async def _handle_role_change(self, member: discord.Member, coro: t.Callable[..., t.Coroutine]) -> None:
- """
- Change `member`'s cooldown role via awaiting `coro` and handle errors.
-
- `coro` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`.
- """
- try:
- await coro(self.bot.get_guild(constants.Guild.id).get_role(constants.Roles.help_cooldown))
- except discord.NotFound:
- log.debug(f"Failed to change role for {member} ({member.id}): member not found")
- except discord.Forbidden:
- log.debug(
- f"Forbidden to change role for {member} ({member.id}); "
- f"possibly due to role hierarchy"
- )
- except discord.HTTPException as e:
- log.error(f"Failed to change role for {member} ({member.id}): {e.status} {e.code}")
-
@lock.lock_arg(NAMESPACE, "message", attrgetter("channel.id"))
@lock.lock_arg(NAMESPACE, "message", attrgetter("author.id"))
@lock.lock_arg(f"{NAMESPACE}.unclaim", "message", attrgetter("author.id"), wait=True)
@@ -128,11 +113,9 @@ class HelpChannels(commands.Cog):
# Handle odd edge case of `message.author` not being a `discord.Member` (see bot#1839)
if not isinstance(message.author, discord.Member):
- log.warning(
- f"{message.author} ({message.author.id}) isn't a member. Not giving cooldown role or sending DM."
- )
+ log.debug(f"{message.author} ({message.author.id}) isn't a member. Not giving cooldown role or sending DM.")
else:
- await self._handle_role_change(message.author, message.author.add_roles)
+ await members.handle_role_change(message.author, message.author.add_roles, self.cooldown_role)
try:
await _message.dm_on_open(message)
@@ -304,6 +287,9 @@ class HelpChannels(commands.Cog):
await self.bot.wait_until_guild_available()
log.trace("Initialising the cog.")
+ self.guild = self.bot.get_guild(constants.Guild.id)
+ self.cooldown_role = self.guild.get_role(constants.Roles.help_cooldown)
+
await self.init_categories()
self.channel_queue = self.create_channel_queue()
@@ -447,11 +433,11 @@ class HelpChannels(commands.Cog):
await _caches.claimants.delete(channel.id)
await _caches.session_participants.delete(channel.id)
- claimant = await members.get_or_fetch_member(self.bot.get_guild(constants.Guild.id), claimant_id)
+ claimant = await members.get_or_fetch_member(self.guild, claimant_id)
if claimant is None:
log.info(f"{claimant_id} left the guild during their help session; the cooldown role won't be removed")
else:
- await self._handle_role_change(claimant, claimant.remove_roles)
+ await members.handle_role_change(claimant, claimant.remove_roles, self.cooldown_role)
await _message.unpin(channel)
await _stats.report_complete_session(channel.id, closed_on)
@@ -592,7 +578,7 @@ class HelpChannels(commands.Cog):
embed = discord.Embed(
title="Currently Helping",
description=f"You're currently helping in {message.channel.mention}",
- color=constants.Colours.soft_green,
+ color=constants.Colours.bright_green,
timestamp=message.created_at
)
embed.add_field(name="Conversation", value=f"[Jump to message]({message.jump_url})")
diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py
index 743dfdd3f..06799fb71 100644
--- a/bot/exts/info/help.py
+++ b/bot/exts/info/help.py
@@ -1,10 +1,12 @@
+from __future__ import annotations
+
import itertools
import re
from collections import namedtuple
from contextlib import suppress
-from typing import List, Union
+from typing import List, Optional, Union
-from discord import Colour, Embed
+from discord import ButtonStyle, Colour, Embed, Emoji, Interaction, PartialEmoji, ui
from discord.ext.commands import Bot, Cog, Command, CommandError, Context, DisabledCommand, Group, HelpCommand
from rapidfuzz import fuzz, process
from rapidfuzz.utils import default_process
@@ -26,6 +28,119 @@ NOT_ALLOWED_TO_RUN_MESSAGE = "***You cannot run this command.***\n\n"
Category = namedtuple("Category", ["name", "description", "cogs"])
+class SubcommandButton(ui.Button):
+ """
+ A button shown in a group's help embed.
+
+ The button represents a subcommand, and pressing it will edit the help embed to that of the subcommand.
+ """
+
+ def __init__(
+ self,
+ help_command: CustomHelpCommand,
+ command: Command,
+ *,
+ style: ButtonStyle = ButtonStyle.primary,
+ label: Optional[str] = None,
+ disabled: bool = False,
+ custom_id: Optional[str] = None,
+ url: Optional[str] = None,
+ emoji: Optional[Union[str, Emoji, PartialEmoji]] = None,
+ row: Optional[int] = None
+ ):
+ super().__init__(
+ style=style, label=label, disabled=disabled, custom_id=custom_id, url=url, emoji=emoji, row=row
+ )
+
+ self.help_command = help_command
+ self.command = command
+
+ async def callback(self, interaction: Interaction) -> None:
+ """Edits the help embed to that of the subcommand."""
+ message = interaction.message
+ if not message:
+ return
+
+ subcommand = self.command
+ if isinstance(subcommand, Group):
+ embed, subcommand_view = await self.help_command.format_group_help(subcommand)
+ else:
+ embed, subcommand_view = await self.help_command.command_formatting(subcommand)
+ await message.edit(embed=embed, view=subcommand_view)
+
+
+class GroupButton(ui.Button):
+ """
+ A button shown in a subcommand's help embed.
+
+ The button represents the parent command, and pressing it will edit the help embed to that of the parent.
+ """
+
+ def __init__(
+ self,
+ help_command: CustomHelpCommand,
+ command: Command,
+ *,
+ style: ButtonStyle = ButtonStyle.secondary,
+ label: Optional[str] = None,
+ disabled: bool = False,
+ custom_id: Optional[str] = None,
+ url: Optional[str] = None,
+ emoji: Optional[Union[str, Emoji, PartialEmoji]] = None,
+ row: Optional[int] = None
+ ):
+ super().__init__(
+ style=style, label=label, disabled=disabled, custom_id=custom_id, url=url, emoji=emoji, row=row
+ )
+
+ self.help_command = help_command
+ self.command = command
+
+ async def callback(self, interaction: Interaction) -> None:
+ """Edits the help embed to that of the parent."""
+ message = interaction.message
+ if not message:
+ return
+
+ embed, group_view = await self.help_command.format_group_help(self.command.parent)
+ await message.edit(embed=embed, view=group_view)
+
+
+class CommandView(ui.View):
+ """
+ The view added to any command's help embed.
+
+ If the command has a parent, a button is added to the view to show that parent's help embed.
+ """
+
+ def __init__(self, help_command: CustomHelpCommand, command: Command):
+ super().__init__()
+
+ if command.parent:
+ self.children.append(GroupButton(help_command, command, emoji="↩️"))
+
+
+class GroupView(CommandView):
+ """
+ The view added to a group's help embed.
+
+ The view generates a SubcommandButton for every subcommand the group has.
+ """
+
+ MAX_BUTTONS_IN_ROW = 5
+ MAX_ROWS = 5
+
+ def __init__(self, help_command: CustomHelpCommand, group: Group, subcommands: list[Command]):
+ super().__init__(help_command, group)
+ # Don't add buttons if only a portion of the subcommands can be shown.
+ if len(subcommands) + len(self.children) > self.MAX_ROWS * self.MAX_BUTTONS_IN_ROW:
+ log.trace(f"Attempted to add navigation buttons for `{group.qualified_name}`, but there was no space.")
+ return
+
+ for subcommand in subcommands:
+ self.add_item(SubcommandButton(help_command, subcommand, label=subcommand.name))
+
+
class HelpQueryNotFound(ValueError):
"""
Raised when a HelpSession Query doesn't match a command or cog.
@@ -148,7 +263,7 @@ class CustomHelpCommand(HelpCommand):
await self.context.send(embed=embed)
- async def command_formatting(self, command: Command) -> Embed:
+ async def command_formatting(self, command: Command) -> tuple[Embed, Optional[CommandView]]:
"""
Takes a command and turns it into an embed.
@@ -186,12 +301,14 @@ class CustomHelpCommand(HelpCommand):
command_details += f"*{formatted_doc or 'No details provided.'}*\n"
embed.description = command_details
- return embed
+ # If the help is invoked in the context of an error, don't show subcommand navigation.
+ view = CommandView(self, command) if not self.context.command_failed else None
+ return embed, view
async def send_command_help(self, command: Command) -> None:
"""Send help for a single command."""
- embed = await self.command_formatting(command)
- message = await self.context.send(embed=embed)
+ embed, view = await self.command_formatting(command)
+ message = await self.context.send(embed=embed, view=view)
await wait_for_deletion(message, (self.context.author.id,))
@staticmethod
@@ -212,25 +329,31 @@ class CustomHelpCommand(HelpCommand):
else:
return "".join(details)
- async def send_group_help(self, group: Group) -> None:
- """Sends help for a group command."""
+ async def format_group_help(self, group: Group) -> tuple[Embed, Optional[CommandView]]:
+ """Formats help for a group command."""
subcommands = group.commands
if len(subcommands) == 0:
# no subcommands, just treat it like a regular command
- await self.send_command_help(group)
- return
+ return await self.command_formatting(group)
# remove commands that the user can't run and are hidden, and sort by name
commands_ = await self.filter_commands(subcommands, sort=True)
- embed = await self.command_formatting(group)
+ embed, _ = await self.command_formatting(group)
command_details = self.get_commands_brief_details(commands_)
if command_details:
embed.description += f"\n**Subcommands:**\n{command_details}"
- message = await self.context.send(embed=embed)
+ # If the help is invoked in the context of an error, don't show subcommand navigation.
+ view = GroupView(self, group, commands_) if not self.context.command_failed else None
+ return embed, view
+
+ async def send_group_help(self, group: Group) -> None:
+ """Sends help for a group command."""
+ embed, view = await self.format_group_help(group)
+ message = await self.context.send(embed=embed, view=view)
await wait_for_deletion(message, (self.context.author.id,))
async def send_cog_help(self, cog: Cog) -> None:
diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py
index 7f4811a43..5b48495dc 100644
--- a/bot/exts/info/information.py
+++ b/bot/exts/info/information.py
@@ -178,7 +178,10 @@ class Information(Cog):
# Server Features are only useful in certain channels
if ctx.channel.id in (
- *constants.MODERATION_CHANNELS, constants.Channels.dev_core, constants.Channels.dev_contrib
+ *constants.MODERATION_CHANNELS,
+ constants.Channels.dev_core,
+ constants.Channels.dev_contrib,
+ constants.Channels.bot_commands
):
features = f"\nFeatures: {', '.join(ctx.guild.features)}"
else:
@@ -419,7 +422,12 @@ class Information(Cog):
activity_output = "No activity"
else:
activity_output.append(user_activity["total_messages"] or "No messages")
- activity_output.append(user_activity["activity_blocks"] or "No activity")
+
+ if (activity_blocks := user_activity.get("activity_blocks")) is not None:
+ # activity_blocks is not included in the response if the user has a lot of messages
+ activity_output.append(activity_blocks or "No activity") # Special case when activity_blocks is 0.
+ else:
+ activity_output.append("Too many to count!")
activity_output = "\n".join(
f"{name}: {metric}" for name, metric in zip(["Messages", "Activity blocks"], activity_output)
diff --git a/bot/exts/info/pypi.py b/bot/exts/info/pypi.py
index c3d2e2a3c..dacf7bc12 100644
--- a/bot/exts/info/pypi.py
+++ b/bot/exts/info/pypi.py
@@ -29,7 +29,7 @@ class PyPi(Cog):
def __init__(self, bot: Bot):
self.bot = bot
- @command(name="pypi", aliases=("package", "pack"))
+ @command(name="pypi", aliases=("package", "pack", "pip"))
async def get_package_info(self, ctx: Context, package: str) -> None:
"""Provide information about a specific package from PyPI."""
embed = Embed(title=random.choice(NEGATIVE_REPLIES), colour=Colours.soft_red)
diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py
index e8e71558b..f6499ecce 100644
--- a/bot/exts/info/site.py
+++ b/bot/exts/info/site.py
@@ -1,3 +1,5 @@
+from textwrap import shorten
+
from discord import Colour, Embed
from discord.ext.commands import Cog, Context, Greedy, group
@@ -123,10 +125,11 @@ class Site(Cog):
# Remove duplicates and sort the rule indices
rules = sorted(set(rules))
+
invalid = ', '.join(str(index) for index in rules if index < 1 or index > len(full_rules))
if invalid:
- await ctx.send(f":x: Invalid rule indices: {invalid}")
+ await ctx.send(shorten(":x: Invalid rule indices: " + invalid, 75, placeholder=' ...'))
return
for rule in rules:
diff --git a/bot/exts/info/subscribe.py b/bot/exts/info/subscribe.py
new file mode 100644
index 000000000..2e6101d27
--- /dev/null
+++ b/bot/exts/info/subscribe.py
@@ -0,0 +1,202 @@
+import calendar
+import operator
+import typing as t
+from dataclasses import dataclass
+
+import arrow
+import discord
+from discord.ext import commands
+from discord.interactions import Interaction
+
+from bot import constants
+from bot.bot import Bot
+from bot.decorators import in_whitelist
+from bot.log import get_logger
+from bot.utils import checks, members, scheduling
+
+
+@dataclass(frozen=True)
+class AssignableRole:
+ """
+ A role that can be assigned to a user.
+
+ months_available is a tuple that signifies what months the role should be
+ self-assignable, using None for when it should always be available.
+ """
+
+ role_id: int
+ months_available: t.Optional[tuple[int]]
+ name: t.Optional[str] = None # This gets populated within Subscribe.init_cog()
+
+ def is_currently_available(self) -> bool:
+ """Check if the role is available for the current month."""
+ if self.months_available is None:
+ return True
+ return arrow.utcnow().month in self.months_available
+
+ def get_readable_available_months(self) -> str:
+ """Get a readable string of the months the role is available."""
+ if self.months_available is None:
+ return f"{self.name} is always available."
+
+ # Join the months together with comma separators, but use "and" for the final seperator.
+ month_names = [calendar.month_name[month] for month in self.months_available]
+ available_months_str = ", ".join(month_names[:-1]) + f" and {month_names[-1]}"
+ return f"{self.name} can only be assigned during {available_months_str}."
+
+
+ASSIGNABLE_ROLES = (
+ AssignableRole(constants.Roles.announcements, None),
+ AssignableRole(constants.Roles.pyweek_announcements, None),
+ AssignableRole(constants.Roles.lovefest, (1, 2)),
+ AssignableRole(constants.Roles.advent_of_code, (11, 12)),
+)
+
+ITEMS_PER_ROW = 3
+DELETE_MESSAGE_AFTER = 300 # Seconds
+
+log = get_logger(__name__)
+
+
+class RoleButtonView(discord.ui.View):
+ """A list of SingleRoleButtons to show to the member."""
+
+ def __init__(self, member: discord.Member):
+ super().__init__()
+ self.interaction_owner = member
+
+ async def interaction_check(self, interaction: Interaction) -> bool:
+ """Ensure that the user clicking the button is the member who invoked the command."""
+ if interaction.user != self.interaction_owner:
+ await interaction.response.send_message(
+ ":x: This is not your command to react to!",
+ ephemeral=True
+ )
+ return False
+ return True
+
+
+class SingleRoleButton(discord.ui.Button):
+ """A button that adds or removes a role from the member depending on it's current state."""
+
+ ADD_STYLE = discord.ButtonStyle.success
+ REMOVE_STYLE = discord.ButtonStyle.red
+ UNAVAILABLE_STYLE = discord.ButtonStyle.secondary
+ LABEL_FORMAT = "{action} role {role_name}."
+ CUSTOM_ID_FORMAT = "subscribe-{role_id}"
+
+ def __init__(self, role: AssignableRole, assigned: bool, row: int):
+ if role.is_currently_available():
+ style = self.REMOVE_STYLE if assigned else self.ADD_STYLE
+ label = self.LABEL_FORMAT.format(action="Remove" if assigned else "Add", role_name=role.name)
+ else:
+ style = self.UNAVAILABLE_STYLE
+ label = f"🔒 {role.name}"
+
+ super().__init__(
+ style=style,
+ label=label,
+ custom_id=self.CUSTOM_ID_FORMAT.format(role_id=role.role_id),
+ row=row,
+ )
+ self.role = role
+ self.assigned = assigned
+
+ async def callback(self, interaction: Interaction) -> None:
+ """Update the member's role and change button text to reflect current text."""
+ if isinstance(interaction.user, discord.User):
+ log.trace("User %s is not a member", interaction.user)
+ await interaction.message.delete()
+ self.view.stop()
+ return
+
+ if not self.role.is_currently_available():
+ await interaction.response.send_message(self.role.get_readable_available_months(), ephemeral=True)
+ return
+
+ await members.handle_role_change(
+ interaction.user,
+ interaction.user.remove_roles if self.assigned else interaction.user.add_roles,
+ discord.Object(self.role.role_id),
+ )
+
+ self.assigned = not self.assigned
+ await self.update_view(interaction)
+ await interaction.response.send_message(
+ self.LABEL_FORMAT.format(action="Added" if self.assigned else "Removed", role_name=self.role.name),
+ ephemeral=True,
+ )
+
+ async def update_view(self, interaction: Interaction) -> None:
+ """Updates the original interaction message with a new view object with the updated buttons."""
+ self.style = self.REMOVE_STYLE if self.assigned else self.ADD_STYLE
+ self.label = self.LABEL_FORMAT.format(action="Remove" if self.assigned else "Add", role_name=self.role.name)
+ try:
+ await interaction.message.edit(view=self.view)
+ except discord.NotFound:
+ log.debug("Subscribe message for %s removed before buttons could be updated", interaction.user)
+ self.view.stop()
+
+
+class Subscribe(commands.Cog):
+ """Cog to allow user to self-assign & remove the roles present in ASSIGNABLE_ROLES."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self.init_task = scheduling.create_task(self.init_cog(), event_loop=self.bot.loop)
+ self.assignable_roles: list[AssignableRole] = []
+ self.guild: discord.Guild = None
+
+ async def init_cog(self) -> None:
+ """Initialise the cog by resolving the role IDs in ASSIGNABLE_ROLES to role names."""
+ await self.bot.wait_until_guild_available()
+
+ self.guild = self.bot.get_guild(constants.Guild.id)
+
+ for role in ASSIGNABLE_ROLES:
+ discord_role = self.guild.get_role(role.role_id)
+ if discord_role is None:
+ log.warning("Could not resolve %d to a role in the guild, skipping.", role.role_id)
+ continue
+ self.assignable_roles.append(
+ AssignableRole(
+ role_id=role.role_id,
+ months_available=role.months_available,
+ name=discord_role.name,
+ )
+ )
+ # Sort unavailable roles to the end of the list
+ self.assignable_roles.sort(key=operator.methodcaller("is_currently_available"), reverse=True)
+
+ @commands.cooldown(1, 10, commands.BucketType.member)
+ @commands.command(name="subscribe")
+ @in_whitelist(channels=(constants.Channels.bot_commands,))
+ async def subscribe_command(self, ctx: commands.Context, *_) -> None: # We don't actually care about the args
+ """Display the member's current state for each role, and allow them to add/remove the roles."""
+ await self.init_task
+
+ button_view = RoleButtonView(ctx.author)
+ author_roles = [role.id for role in ctx.author.roles]
+ for index, role in enumerate(self.assignable_roles):
+ row = index // ITEMS_PER_ROW
+ button_view.add_item(SingleRoleButton(role, role.role_id in author_roles, row))
+
+ await ctx.reply(
+ "Click the buttons below to add or remove your roles!",
+ view=button_view,
+ delete_after=DELETE_MESSAGE_AFTER,
+ )
+
+ # This cannot be static (must have a __func__ attribute).
+ async def cog_command_error(self, ctx: commands.Context, error: Exception) -> None:
+ """Check for & ignore any InWhitelistCheckFailure."""
+ if isinstance(error, checks.InWhitelistCheckFailure):
+ error.handled = True
+
+
+def setup(bot: Bot) -> None:
+ """Load the Subscribe cog."""
+ if len(ASSIGNABLE_ROLES) > ITEMS_PER_ROW*5: # Discord limits views to 5 rows of buttons.
+ log.error("Too many roles for 5 rows, not loading the Subscribe cog.")
+ else:
+ bot.add_cog(Subscribe(bot))
diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py
index 94494b983..826265aa3 100644
--- a/bot/exts/moderation/clean.py
+++ b/bot/exts/moderation/clean.py
@@ -293,7 +293,8 @@ class Clean(Cog):
return deleted
if len(to_delete) > 0:
# Deleting any leftover messages if there are any
- await channel.delete_messages(to_delete)
+ with suppress(NotFound):
+ await channel.delete_messages(to_delete)
deleted.extend(to_delete)
if not self.cleaning:
diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py
index e265e29d3..77dfad255 100644
--- a/bot/exts/moderation/incidents.py
+++ b/bot/exts/moderation/incidents.py
@@ -1,16 +1,18 @@
import asyncio
-import typing as t
+import re
from datetime import datetime
from enum import Enum
+from typing import Optional
import discord
-from discord.ext.commands import Cog
+from async_rediscache import RedisCache
+from discord.ext.commands import Cog, Context, MessageConverter, MessageNotFound
from bot.bot import Bot
-from bot.constants import Channels, Colours, Emojis, Guild, Webhooks
+from bot.constants import Channels, Colours, Emojis, Guild, Roles, Webhooks
from bot.log import get_logger
from bot.utils import scheduling
-from bot.utils.messages import sub_clyde
+from bot.utils.messages import format_user, sub_clyde
log = get_logger(__name__)
@@ -22,6 +24,12 @@ CRAWL_LIMIT = 50
# Seconds for `crawl_task` to sleep after adding reactions to a message
CRAWL_SLEEP = 2
+DISCORD_MESSAGE_LINK_RE = re.compile(
+ r"(https?:\/\/(?:(ptb|canary|www)\.)?discord(?:app)?\.com\/channels\/"
+ r"[0-9]{15,20}"
+ r"\/[0-9]{15,20}\/[0-9]{15,20})"
+)
+
class Signal(Enum):
"""
@@ -37,17 +45,17 @@ class Signal(Enum):
# Reactions from non-mod roles will be removed
-ALLOWED_ROLES: t.Set[int] = set(Guild.moderation_roles)
+ALLOWED_ROLES: set[int] = set(Guild.moderation_roles)
# Message must have all of these emoji to pass the `has_signals` check
-ALL_SIGNALS: t.Set[str] = {signal.value for signal in Signal}
+ALL_SIGNALS: set[str] = {signal.value for signal in Signal}
# An embed coupled with an optional file to be dispatched
# If the file is not None, the embed attempts to show it in its body
-FileEmbed = t.Tuple[discord.Embed, t.Optional[discord.File]]
+FileEmbed = tuple[discord.Embed, Optional[discord.File]]
-async def download_file(attachment: discord.Attachment) -> t.Optional[discord.File]:
+async def download_file(attachment: discord.Attachment) -> Optional[discord.File]:
"""
Download & return `attachment` file.
@@ -121,7 +129,7 @@ def is_incident(message: discord.Message) -> bool:
return all(conditions)
-def own_reactions(message: discord.Message) -> t.Set[str]:
+def own_reactions(message: discord.Message) -> set[str]:
"""Get the set of reactions placed on `message` by the bot itself."""
return {str(reaction.emoji) for reaction in message.reactions if reaction.me}
@@ -131,6 +139,108 @@ def has_signals(message: discord.Message) -> bool:
return ALL_SIGNALS.issubset(own_reactions(message))
+def shorten_text(text: str) -> str:
+ """
+ Truncate the text if there are over 3 lines or 300 characters, or if it is a single word.
+
+ The maximum length of the string would be 303 characters across 3 lines at maximum.
+ """
+ original_length = len(text)
+ # Truncate text to a maximum of 300 characters
+ if len(text) > 300:
+ text = text[:300]
+
+ # Limit to a maximum of three lines
+ text = "\n".join(text.split("\n", maxsplit=3)[:3])
+
+ # If it is a single word, then truncate it to 50 characters
+ if text.find(" ") == -1:
+ text = text[:50]
+
+ # Remove extra whitespaces from the `text`
+ text = text.strip()
+
+ # Add placeholder if the text was shortened
+ if len(text) < original_length:
+ text = f"{text}..."
+
+ return text
+
+
+async def make_message_link_embed(ctx: Context, message_link: str) -> Optional[discord.Embed]:
+ """
+ Create an embedded representation of the discord message link contained in the incident report.
+
+ The Embed would contain the following information -->
+ Author: @Jason Terror ♦ (736234578745884682)
+ Channel: Special/#bot-commands (814190307980607493)
+ Content: This is a very important message!
+ """
+ embed = None
+
+ try:
+ message: discord.Message = await MessageConverter().convert(ctx, message_link)
+ except MessageNotFound:
+ mod_logs_channel = ctx.bot.get_channel(Channels.mod_log)
+
+ last_100_logs: list[discord.Message] = await mod_logs_channel.history(limit=100).flatten()
+
+ for log_entry in last_100_logs:
+ if not log_entry.embeds:
+ continue
+
+ log_embed: discord.Embed = log_entry.embeds[0]
+ if (
+ log_embed.author.name == "Message deleted"
+ and f"[Jump to message]({message_link})" in log_embed.description
+ ):
+ embed = discord.Embed(
+ colour=discord.Colour.dark_gold(),
+ title="Deleted Message Link",
+ description=(
+ f"Found <#{Channels.mod_log}> entry for deleted message: "
+ f"[Jump to message]({log_entry.jump_url})."
+ )
+ )
+ if not embed:
+ embed = discord.Embed(
+ colour=discord.Colour.red(),
+ title="Bad Message Link",
+ description=f"Message {message_link} not found."
+ )
+ except discord.DiscordException as e:
+ log.exception(f"Failed to make message link embed for '{message_link}', raised exception: {e}")
+ else:
+ channel = message.channel
+ if not channel.permissions_for(channel.guild.get_role(Roles.helpers)).view_channel:
+ log.info(
+ f"Helpers don't have read permissions in #{channel.name},"
+ f" not sending message link embed for {message_link}"
+ )
+ return
+
+ embed = discord.Embed(
+ colour=discord.Colour.gold(),
+ description=(
+ f"**Author:** {format_user(message.author)}\n"
+ f"**Channel:** {channel.mention} ({channel.category}"
+ f"{f'/#{channel.parent.name} - ' if isinstance(channel, discord.Thread) else '/#'}"
+ f"{channel.name})\n"
+ ),
+ timestamp=message.created_at
+ )
+ embed.add_field(
+ name="Content",
+ value=shorten_text(message.content) if message.content else "[No Message Content]"
+ )
+ embed.set_footer(text=f"Message ID: {message.id}")
+
+ if message.attachments:
+ embed.set_image(url=message.attachments[0].url)
+
+ return embed
+
+
async def add_signals(incident: discord.Message) -> None:
"""
Add `Signal` member emoji to `incident` as reactions.
@@ -168,6 +278,7 @@ class Incidents(Cog):
* See: `crawl_incidents`
On message:
+ * Run message through `extract_message_links` and send them into the channel
* Add `Signal` member emoji if message qualifies as an incident
* Ignore messages starting with #
* Use this if verbal communication is necessary
@@ -181,18 +292,35 @@ class Incidents(Cog):
* If `Signal.ACTIONED` or `Signal.NOT_ACTIONED` were chosen, attempt to
relay the incident message to #incidents-archive
* If relay successful, delete original message
+ * Delete quotation message if cached
* See: `on_raw_reaction_add`
Please refer to function docstrings for implementation details.
"""
+ # This dictionary maps an incident report message to the message link embed's ID
+ # RedisCache[discord.Message.id, discord.Message.id]
+ message_link_embeds_cache = RedisCache()
+
def __init__(self, bot: Bot) -> None:
"""Prepare `event_lock` and schedule `crawl_task` on start-up."""
self.bot = bot
+ self.incidents_webhook = None
+
+ scheduling.create_task(self.fetch_webhook(), event_loop=self.bot.loop)
self.event_lock = asyncio.Lock()
self.crawl_task = scheduling.create_task(self.crawl_incidents(), event_loop=self.bot.loop)
+ async def fetch_webhook(self) -> None:
+ """Fetch the incidents webhook object, so we can post message link embeds to it."""
+ await self.bot.wait_until_guild_available()
+
+ try:
+ self.incidents_webhook = await self.bot.fetch_webhook(Webhooks.incidents)
+ except discord.HTTPException:
+ log.error(f"Failed to fetch incidents webhook with id `{Webhooks.incidents}`.")
+
async def crawl_incidents(self) -> None:
"""
Crawl #incidents and add missing emoji where necessary.
@@ -292,8 +420,11 @@ class Incidents(Cog):
This ensures that if there is a racing event awaiting the lock, it will fail to find the
message, and will abort. There is a `timeout` to ensure that this doesn't hold the lock
forever should something go wrong.
+
+ Deletes cache value (`message_link_embeds_cache`) of `incident` if it exists. It then removes the
+ webhook message for that particular link from the channel.
"""
- members_roles: t.Set[int] = {role.id for role in member.roles}
+ members_roles: set[int] = {role.id for role in member.roles}
if not members_roles & ALLOWED_ROLES: # Intersection is truthy on at least 1 common element
log.debug(f"Removing invalid reaction: user {member} is not permitted to send signals")
try:
@@ -340,7 +471,11 @@ class Incidents(Cog):
else:
log.trace("Deletion was confirmed")
- async def resolve_message(self, message_id: int) -> t.Optional[discord.Message]:
+ if self.incidents_webhook:
+ # Deletes the message link embeds found in cache from the channel and cache.
+ await self.delete_msg_link_embed(incident.id)
+
+ async def resolve_message(self, message_id: int) -> Optional[discord.Message]:
"""
Get `discord.Message` for `message_id` from cache, or API.
@@ -355,7 +490,7 @@ class Incidents(Cog):
"""
await self.bot.wait_until_guild_available() # First make sure that the cache is ready
log.trace(f"Resolving message for: {message_id=}")
- message: t.Optional[discord.Message] = self.bot._connection._get_message(message_id)
+ message: Optional[discord.Message] = self.bot._connection._get_message(message_id)
if message is not None:
log.trace("Message was found in cache")
@@ -419,9 +554,107 @@ class Incidents(Cog):
@Cog.listener()
async def on_message(self, message: discord.Message) -> None:
- """Pass `message` to `add_signals` if and only if it satisfies `is_incident`."""
- if is_incident(message):
- await add_signals(message)
+ """
+ Pass `message` to `add_signals` and `extract_message_links` if it satisfies `is_incident`.
+
+ If `message` is an incident report, then run it through `extract_message_links` to get all
+ the message link embeds (embeds which contain information about that particular link).
+ These message link embeds are then sent into the channel.
+
+ Also passes the message into `add_signals` if the message is an incident.
+ """
+ if not is_incident(message):
+ return
+
+ await add_signals(message)
+
+ # Only use this feature if incidents webhook embed is found
+ if self.incidents_webhook:
+ if embed_list := await self.extract_message_links(message):
+ await self.send_message_link_embeds(embed_list, message, self.incidents_webhook)
+
+ @Cog.listener()
+ async def on_raw_message_delete(self, payload: discord.RawMessageDeleteEvent) -> None:
+ """
+ Delete message link embeds for `payload.message_id`.
+
+ Search through the cache for message, if found delete it from cache and channel.
+ """
+ if self.incidents_webhook:
+ await self.delete_msg_link_embed(payload.message_id)
+
+ async def extract_message_links(self, message: discord.Message) -> Optional[list[discord.Embed]]:
+ """
+ Check if there's any message links in the text content.
+
+ Then pass the message_link into `make_message_link_embed` to format an
+ embed for it containing information about the link.
+
+ As Discord only allows a max of 10 embeds in a single webhook, just send the
+ first 10 embeds and don't care about the rest.
+
+ If no links are found for the message, just log a trace statement.
+ """
+ message_links = DISCORD_MESSAGE_LINK_RE.findall(message.content)
+ if not message_links:
+ log.trace(
+ f"No message links detected on incident message with id {message.id}."
+ )
+ return
+
+ embeds = []
+ for message_link in message_links[:10]:
+ ctx = await self.bot.get_context(message)
+ embed = await make_message_link_embed(ctx, message_link[0])
+ if embed:
+ embeds.append(embed)
+
+ return embeds
+
+ async def send_message_link_embeds(
+ self,
+ webhook_embed_list: list,
+ message: discord.Message,
+ webhook: discord.Webhook,
+ ) -> Optional[int]:
+ """
+ Send message link embeds to #incidents channel.
+
+ Using the `webhook` passed in as a parameter to send
+ the embeds in the `webhook_embed_list` parameter.
+
+ After sending each embed it maps the `message.id`
+ to the `webhook_msg_ids` IDs in the async redis-cache.
+ """
+ try:
+ webhook_msg = await webhook.send(
+ embeds=[embed for embed in webhook_embed_list if embed],
+ username=sub_clyde(message.author.name),
+ avatar_url=message.author.display_avatar.url,
+ wait=True,
+ )
+ except discord.DiscordException:
+ log.exception(
+ f"Failed to send message link embed {message.id} to #incidents."
+ )
+ else:
+ await self.message_link_embeds_cache.set(message.id, webhook_msg.id)
+ log.trace("Message link embeds sent successfully to #incidents!")
+ return webhook_msg.id
+
+ async def delete_msg_link_embed(self, message_id: int) -> None:
+ """Delete the Discord message link message found in cache for `message_id`."""
+ log.trace("Deleting Discord message link's webhook message.")
+ webhook_msg_id = await self.message_link_embeds_cache.get(int(message_id))
+
+ if webhook_msg_id:
+ try:
+ await self.incidents_webhook.delete_message(webhook_msg_id)
+ except discord.errors.NotFound:
+ log.trace(f"Incidents message link embed (`{webhook_msg_id}`) has already been deleted, skipping.")
+
+ await self.message_link_embeds_cache.delete(message_id)
+ log.trace("Successfully deleted discord links webhook message.")
def setup(bot: Bot) -> None:
diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py
index c0ef80e3d..bb3cc5380 100644
--- a/bot/exts/moderation/infraction/_utils.py
+++ b/bot/exts/moderation/infraction/_utils.py
@@ -30,9 +30,9 @@ Infraction = t.Dict[str, t.Union[str, int, bool]]
APPEAL_SERVER_INVITE = "https://discord.gg/WXrCJxWBnm"
INFRACTION_TITLE = "Please review our rules"
-INFRACTION_APPEAL_SERVER_FOOTER = f"\n\nTo appeal this infraction, join our [appeals server]({APPEAL_SERVER_INVITE})."
+INFRACTION_APPEAL_SERVER_FOOTER = f"\nTo appeal this infraction, join our [appeals server]({APPEAL_SERVER_INVITE})."
INFRACTION_APPEAL_MODMAIL_FOOTER = (
- '\n\nIf you would like to discuss or appeal this infraction, '
+ '\nIf you would like to discuss or appeal this infraction, '
'send a message to the ModMail bot.'
)
INFRACTION_AUTHOR_NAME = "Infraction information"
diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py
index 0a33ac5e2..a833eb227 100644
--- a/bot/exts/moderation/infraction/management.py
+++ b/bot/exts/moderation/infraction/management.py
@@ -243,8 +243,9 @@ class ModManagement(commands.Cog):
else:
user_str = str(user.id)
+ formatted_infraction_count = self.format_infraction_count(len(infraction_list))
embed = discord.Embed(
- title=f"Infractions for {user_str} ({len(infraction_list)} total)",
+ title=f"Infractions for {user_str} ({formatted_infraction_count} total)",
colour=discord.Colour.orange()
)
await self.send_infraction_list(ctx, embed, infraction_list)
@@ -256,15 +257,70 @@ class ModManagement(commands.Cog):
'bot/infractions/expanded',
params={'search': reason}
)
+
+ formatted_infraction_count = self.format_infraction_count(len(infraction_list))
embed = discord.Embed(
- title=f"Infractions matching `{reason}` ({len(infraction_list)} total)",
+ title=f"Infractions matching `{reason}` ({formatted_infraction_count} total)",
colour=discord.Colour.orange()
)
await self.send_infraction_list(ctx, embed, infraction_list)
# endregion
+ # region: Search for infractions by given actor
+
+ @infraction_group.command(name="by", aliases=("b",))
+ async def search_by_actor(
+ self,
+ ctx: Context,
+ actor: t.Union[t.Literal["m", "me"], UnambiguousUser],
+ oldest_first: bool = False
+ ) -> None:
+ """
+ Search for infractions made by `actor`.
+
+ Use "m" or "me" as the `actor` to get infractions by author.
+
+ Use "1" for `oldest_first` to send oldest infractions first.
+ """
+ if isinstance(actor, str):
+ actor = ctx.author
+
+ if oldest_first:
+ ordering = 'inserted_at' # oldest infractions first
+ else:
+ ordering = '-inserted_at' # newest infractions first
+
+ infraction_list = await self.bot.api_client.get(
+ 'bot/infractions/expanded',
+ params={
+ 'actor__id': str(actor.id),
+ 'ordering': ordering
+ }
+ )
+
+ formatted_infraction_count = self.format_infraction_count(len(infraction_list))
+ embed = discord.Embed(
+ title=f"Infractions by {actor} ({formatted_infraction_count} total)",
+ colour=discord.Colour.orange()
+ )
+
+ await self.send_infraction_list(ctx, embed, infraction_list)
+
+ # endregion
# region: Utility functions
+ @staticmethod
+ def format_infraction_count(infraction_count: int) -> str:
+ """
+ Returns a string-formatted infraction count.
+
+ API limits returned infractions to a maximum of 100, so if `infraction_count`
+ is 100 then we return `"100+"`. Otherwise, return `str(infraction_count)`.
+ """
+ if infraction_count == 100:
+ return "100+"
+ return str(infraction_count)
+
async def send_infraction_list(
self,
ctx: Context,
diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py
index 462f8533d..91709e5e5 100644
--- a/bot/exts/moderation/modlog.py
+++ b/bot/exts/moderation/modlog.py
@@ -41,7 +41,6 @@ class ModLog(Cog, name="ModLog"):
self.bot = bot
self._ignored = {event: [] for event in Event}
- self._cached_deletes = []
self._cached_edits = []
async def upload_log(
@@ -552,24 +551,22 @@ class ModLog(Cog, name="ModLog"):
return channel.id in GuildConstant.modlog_blacklist
- @Cog.listener()
- async def on_message_delete(self, message: discord.Message) -> None:
- """Log message delete event to message change log."""
+ async def log_cached_deleted_message(self, message: discord.Message) -> None:
+ """
+ Log the message's details to message change log.
+
+ This is called when a cached message is deleted.
+ """
channel = message.channel
author = message.author
if self.is_message_blacklisted(message):
return
- self._cached_deletes.append(message.id)
-
if message.id in self._ignored[Event.message_delete]:
self._ignored[Event.message_delete].remove(message.id)
return
- if author.bot:
- return
-
if channel.category:
response = (
f"**Author:** {format_user(author)}\n"
@@ -610,17 +607,14 @@ class ModLog(Cog, name="ModLog"):
channel_id=Channels.message_log
)
- @Cog.listener()
- async def on_raw_message_delete(self, event: discord.RawMessageDeleteEvent) -> None:
- """Log raw message delete event to message change log."""
- if self.is_channel_ignored(event.channel_id):
- return
-
- await asyncio.sleep(1) # Wait here in case the normal event was fired
+ async def log_uncached_deleted_message(self, event: discord.RawMessageDeleteEvent) -> None:
+ """
+ Log the message's details to message change log.
- if event.message_id in self._cached_deletes:
- # It was in the cache and the normal event was fired, so we can just ignore it
- self._cached_deletes.remove(event.message_id)
+ This is called when a message absent from the cache is deleted.
+ Hence, the message contents aren't logged.
+ """
+ if self.is_channel_ignored(event.channel_id):
return
if event.message_id in self._ignored[Event.message_delete]:
@@ -652,6 +646,14 @@ class ModLog(Cog, name="ModLog"):
)
@Cog.listener()
+ async def on_raw_message_delete(self, event: discord.RawMessageDeleteEvent) -> None:
+ """Log message deletions to message change log."""
+ if event.cached_message is not None:
+ await self.log_cached_deleted_message(event.cached_message)
+ else:
+ await self.log_uncached_deleted_message(event)
+
+ @Cog.listener()
async def on_message_edit(self, msg_before: discord.Message, msg_after: discord.Message) -> None:
"""Log message edit event to message change log."""
if self.is_message_blacklisted(msg_before):
@@ -773,6 +775,10 @@ class ModLog(Cog, name="ModLog"):
@Cog.listener()
async def on_thread_update(self, before: Thread, after: Thread) -> None:
"""Log thread archiving, un-archiving and name edits."""
+ if self.is_channel_ignored(after.id):
+ log.trace("Ignoring update of thread %s (%d)", after.mention, after.id)
+ return
+
if before.name != after.name:
await self.send_log_message(
Icons.hash_blurple,
@@ -809,6 +815,10 @@ class ModLog(Cog, name="ModLog"):
@Cog.listener()
async def on_thread_delete(self, thread: Thread) -> None:
"""Log thread deletion."""
+ if self.is_channel_ignored(thread.id):
+ log.trace("Ignoring deletion of thread %s (%d)", thread.mention, thread.id)
+ return
+
await self.send_log_message(
Icons.hash_red,
Colours.soft_red,
@@ -827,6 +837,10 @@ class ModLog(Cog, name="ModLog"):
if thread.me:
return
+ if self.is_channel_ignored(thread.id):
+ log.trace("Ignoring creation of thread %s (%d)", thread.mention, thread.id)
+ return
+
await self.send_log_message(
Icons.hash_green,
Colours.soft_green,
diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py
index ed5571d2a..37338d19c 100644
--- a/bot/exts/moderation/verification.py
+++ b/bot/exts/moderation/verification.py
@@ -5,9 +5,7 @@ from discord.ext.commands import Cog, Context, command, has_any_role
from bot import constants
from bot.bot import Bot
-from bot.decorators import in_whitelist
from bot.log import get_logger
-from bot.utils.checks import InWhitelistCheckFailure
log = get_logger(__name__)
@@ -29,11 +27,11 @@ You can find a copy of our rules for reference at <https://pythondiscord.com/pag
Additionally, if you'd like to receive notifications for the announcements \
we post in <#{constants.Channels.announcements}>
-from time to time, you can send `!subscribe` to <#{constants.Channels.bot_commands}> at any time \
+from time to time, you can send `{constants.Bot.prefix}subscribe` to <#{constants.Channels.bot_commands}> at any time \
to assign yourself the **Announcements** role. We'll mention this role every time we make an announcement.
-If you'd like to unsubscribe from the announcement notifications, simply send `!unsubscribe` to \
-<#{constants.Channels.bot_commands}>.
+If you'd like to unsubscribe from the announcement notifications, simply send `{constants.Bot.prefix}subscribe` to \
+<#{constants.Channels.bot_commands}> and click the role again!.
To introduce you to our community, we've made the following video:
https://youtu.be/ZH26PuX3re0
@@ -61,11 +59,9 @@ async def safe_dm(coro: t.Coroutine) -> None:
class Verification(Cog):
"""
- User verification and role management.
+ User verification.
Statistics are collected in the 'verification.' namespace.
-
- Additionally, this cog offers the !subscribe and !unsubscribe commands,
"""
def __init__(self, bot: Bot) -> None:
@@ -108,67 +104,8 @@ class Verification(Cog):
log.exception("DM dispatch failed on unexpected error code")
# endregion
- # region: subscribe commands
-
- @command(name='subscribe')
- @in_whitelist(channels=(constants.Channels.bot_commands,))
- async def subscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args
- """Subscribe to announcement notifications by assigning yourself the role."""
- has_role = False
-
- for role in ctx.author.roles:
- if role.id == constants.Roles.announcements:
- has_role = True
- break
-
- if has_role:
- await ctx.send(f"{ctx.author.mention} You're already subscribed!")
- return
-
- log.debug(f"{ctx.author} called !subscribe. Assigning the 'Announcements' role.")
- await ctx.author.add_roles(discord.Object(constants.Roles.announcements), reason="Subscribed to announcements")
-
- log.trace(f"Deleting the message posted by {ctx.author}.")
-
- await ctx.send(
- f"{ctx.author.mention} Subscribed to <#{constants.Channels.announcements}> notifications.",
- )
-
- @command(name='unsubscribe')
- @in_whitelist(channels=(constants.Channels.bot_commands,))
- async def unsubscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args
- """Unsubscribe from announcement notifications by removing the role from yourself."""
- has_role = False
-
- for role in ctx.author.roles:
- if role.id == constants.Roles.announcements:
- has_role = True
- break
-
- if not has_role:
- await ctx.send(f"{ctx.author.mention} You're already unsubscribed!")
- return
-
- log.debug(f"{ctx.author} called !unsubscribe. Removing the 'Announcements' role.")
- await ctx.author.remove_roles(
- discord.Object(constants.Roles.announcements), reason="Unsubscribed from announcements"
- )
-
- log.trace(f"Deleting the message posted by {ctx.author}.")
-
- await ctx.send(
- f"{ctx.author.mention} Unsubscribed from <#{constants.Channels.announcements}> notifications."
- )
-
- # endregion
# region: miscellaneous
- # This cannot be static (must have a __func__ attribute).
- async def cog_command_error(self, ctx: Context, error: Exception) -> None:
- """Check for & ignore any InWhitelistCheckFailure."""
- if isinstance(error, InWhitelistCheckFailure):
- error.handled = True
-
@command(name='verify')
@has_any_role(*constants.MODERATION_ROLES)
async def perform_manual_verification(self, ctx: Context, user: discord.Member) -> None:
diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py
index 31799ec73..ae55a03a0 100644
--- a/bot/exts/moderation/voice_gate.py
+++ b/bot/exts/moderation/voice_gate.py
@@ -171,8 +171,12 @@ class VoiceGate(Cog):
),
"total_messages": data["total_messages"] < GateConf.minimum_messages,
"voice_banned": data["voice_banned"],
- "activity_blocks": data["activity_blocks"] < GateConf.minimum_activity_blocks
}
+ if activity_blocks := data.get("activity_blocks"):
+ # activity_blocks is not included in the response if the user has a lot of messages.
+ # Only check if the user has enough activity blocks if it is included.
+ checks["activity_blocks"] = activity_blocks < GateConf.minimum_activity_blocks
+
failed = any(checks.values())
failed_reasons = [MESSAGE_FIELD_MAP[key] for key, value in checks.items() if value is True]
[self.bot.stats.incr(f"voice_gate.failed.{key}") for key, value in checks.items() if value is True]
diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py
index 2fafaec97..8fa0be5b1 100644
--- a/bot/exts/recruitment/talentpool/_cog.py
+++ b/bot/exts/recruitment/talentpool/_cog.py
@@ -483,12 +483,9 @@ class TalentPool(Cog, name="Talentpool"):
@has_any_role(*MODERATION_ROLES)
async def get_review(self, ctx: Context, user_id: int) -> None:
"""Get the user's review as a markdown file."""
- review = (await self.reviewer.make_review(user_id))[0]
- if review:
- file = discord.File(StringIO(review), f"{user_id}_review.md")
- await ctx.send(file=file)
- else:
- await ctx.send(f"There doesn't appear to be an active nomination for {user_id}")
+ review, _, _ = await self.reviewer.make_review(user_id)
+ file = discord.File(StringIO(review), f"{user_id}_review.md")
+ await ctx.send(file=file)
@nomination_group.command(aliases=('review',))
@has_any_role(*MODERATION_ROLES)
@@ -501,7 +498,7 @@ class TalentPool(Cog, name="Talentpool"):
await ctx.message.add_reaction(Emojis.check_mark)
@Cog.listener()
- async def on_member_ban(self, guild: Guild, user: Union[MemberOrUser]) -> None:
+ async def on_member_ban(self, guild: Guild, user: MemberOrUser) -> None:
"""Remove `user` from the talent pool after they are banned."""
await self.end_nomination(user.id, "User was banned.")
@@ -516,6 +513,9 @@ class TalentPool(Cog, name="Talentpool"):
if payload.channel_id != Channels.nomination_voting:
return
+ if payload.user_id == self.bot.user.id:
+ return
+
message: PartialMessage = self.bot.get_channel(payload.channel_id).get_partial_message(payload.message_id)
emoji = str(payload.emoji)
diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py
index d880c524c..110ac47bc 100644
--- a/bot/exts/recruitment/talentpool/_review.py
+++ b/bot/exts/recruitment/talentpool/_review.py
@@ -10,12 +10,12 @@ from typing import List, Optional, Union
import arrow
from dateutil.parser import isoparse
-from discord import Embed, Emoji, Member, Message, NoMoreItems, PartialMessage, TextChannel
+from discord import Embed, Emoji, Member, Message, NoMoreItems, NotFound, PartialMessage, TextChannel
from discord.ext.commands import Context
from bot.api import ResponseCodeError
from bot.bot import Bot
-from bot.constants import Channels, Colours, Emojis, Guild
+from bot.constants import Channels, Colours, DEFAULT_THREAD_ARCHIVE_TIME, Emojis, Guild, Roles
from bot.log import get_logger
from bot.utils.members import get_or_fetch_member
from bot.utils.messages import count_unique_users_reaction, pin_no_system_message
@@ -36,9 +36,8 @@ MAX_MESSAGE_SIZE = 2000
MAX_EMBED_SIZE = 4000
# Regex for finding the first message of a nomination, and extracting the nominee.
-# Historic nominations will have 2 role mentions at the start, new ones won't, optionally match for this.
NOMINATION_MESSAGE_REGEX = re.compile(
- r"(?:<@&\d+> <@&\d+>\n)*?<@!?(\d+?)> \(.+#\d{4}\) for Helper!\n\n\*\*Nominated by:\*\*",
+ r"<@!?(\d+)> \(.+#\d{4}\) for Helper!\n\n",
re.MULTILINE
)
@@ -78,14 +77,14 @@ class Reviewer:
async def post_review(self, user_id: int, update_database: bool) -> None:
"""Format the review of a user and post it to the nomination voting channel."""
- review, reviewed_emoji = await self.make_review(user_id)
- if not review:
+ review, reviewed_emoji, nominee = await self.make_review(user_id)
+ if not nominee:
return
guild = self.bot.get_guild(Guild.id)
channel = guild.get_channel(Channels.nomination_voting)
- log.trace(f"Posting the review of {user_id}")
+ log.trace(f"Posting the review of {nominee} ({nominee.id})")
messages = await self._bulk_send(channel, review)
await pin_no_system_message(messages[0])
@@ -95,12 +94,18 @@ class Reviewer:
for reaction in (reviewed_emoji, "\N{THUMBS UP SIGN}", "\N{THUMBS DOWN SIGN}"):
await last_message.add_reaction(reaction)
+ thread = await last_message.create_thread(
+ name=f"Nomination - {nominee}",
+ auto_archive_duration=DEFAULT_THREAD_ARCHIVE_TIME
+ )
+ await thread.send(fr"<@&{Roles.mod_team}> <@&{Roles.admins}>")
+
if update_database:
nomination = self._pool.cache.get(user_id)
await self.bot.api_client.patch(f"bot/nominations/{nomination['id']}", json={"reviewed": True})
- async def make_review(self, user_id: int) -> typing.Tuple[str, Optional[Emoji]]:
- """Format a generic review of a user and return it with the reviewed emoji."""
+ async def make_review(self, user_id: int) -> typing.Tuple[str, Optional[Emoji], Optional[Member]]:
+ """Format a generic review of a user and return it with the reviewed emoji and the user themselves."""
log.trace(f"Formatting the review of {user_id}")
# Since `cache` is a defaultdict, we should take care
@@ -110,17 +115,17 @@ class Reviewer:
nomination = self._pool.cache.get(user_id)
if not nomination:
log.trace(f"There doesn't appear to be an active nomination for {user_id}")
- return "", None
+ return f"There doesn't appear to be an active nomination for {user_id}", None, None
guild = self.bot.get_guild(Guild.id)
- member = await get_or_fetch_member(guild, user_id)
+ nominee = await get_or_fetch_member(guild, user_id)
- if not member:
+ if not nominee:
return (
f"I tried to review the user with ID `{user_id}`, but they don't appear to be on the server :pensive:"
- ), None
+ ), None, None
- opening = f"{member.mention} ({member}) for Helper!"
+ opening = f"{nominee.mention} ({nominee}) for Helper!"
current_nominations = "\n\n".join(
f"**<@{entry['actor']}>:** {entry['reason'] or '*no reason given*'}"
@@ -128,7 +133,7 @@ class Reviewer:
)
current_nominations = f"**Nominated by:**\n{current_nominations}"
- review_body = await self._construct_review_body(member)
+ review_body = await self._construct_review_body(nominee)
reviewed_emoji = self._random_ducky(guild)
vote_request = (
@@ -138,7 +143,7 @@ class Reviewer:
)
review = "\n\n".join((opening, current_nominations, review_body, vote_request))
- return review, reviewed_emoji
+ return review, reviewed_emoji, nominee
async def archive_vote(self, message: PartialMessage, passed: bool) -> None:
"""Archive this vote to #nomination-archive."""
@@ -210,8 +215,18 @@ class Reviewer:
colour=colour
))
+ # Thread channel IDs are the same as the message ID of the parent message.
+ nomination_thread = message.guild.get_thread(message.id)
+ if not nomination_thread:
+ log.warning(f"Could not find a thread linked to {message.channel.id}-{message.id}")
+ return
+
for message_ in messages:
- await message_.delete()
+ with contextlib.suppress(NotFound):
+ await message_.delete()
+
+ with contextlib.suppress(NotFound):
+ await nomination_thread.edit(archived=True)
async def _construct_review_body(self, member: Member) -> str:
"""Formats the body of the nomination, with details of activity, infractions, and previous nominations."""
@@ -360,10 +375,10 @@ class Reviewer:
@staticmethod
def _random_ducky(guild: Guild) -> Union[Emoji, str]:
- """Picks a random ducky emoji. If no duckies found returns :eyes:."""
+ """Picks a random ducky emoji. If no duckies found returns 👀."""
duckies = [emoji for emoji in guild.emojis if emoji.name.startswith("ducky")]
if not duckies:
- return ":eyes:"
+ return "\N{EYES}"
return random.choice(duckies)
@staticmethod
diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py
index 86e4505fa..90677b2dd 100644
--- a/bot/exts/utils/reminders.py
+++ b/bot/exts/utils/reminders.py
@@ -214,7 +214,7 @@ class Reminders(Cog):
@group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True)
async def remind_group(
- self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: str
+ self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: t.Optional[str] = None
) -> None:
"""
Commands for managing your reminders.
@@ -234,7 +234,7 @@ class Reminders(Cog):
@remind_group.command(name="new", aliases=("add", "create"))
async def new_reminder(
- self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: str
+ self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: t.Optional[str] = None
) -> None:
"""
Set yourself a simple reminder.
@@ -283,6 +283,20 @@ class Reminders(Cog):
mention_ids = [mention.id for mention in mentions]
+ # If `content` isn't provided then we try to get message content of a replied message
+ if not content:
+ if reference := ctx.message.reference:
+ if isinstance((resolved_message := reference.resolved), discord.Message):
+ content = resolved_message.content
+ # If we weren't able to get the content of a replied message
+ if content is None:
+ await send_denial(ctx, "Your reminder must have a content and/or reply to a message.")
+ return
+
+ # If the replied message has no content (e.g. only attachments/embeds)
+ if content == "":
+ content = "See referenced message."
+
# Now we can attempt to actually set the reminder.
reminder = await self.bot.api_client.post(
'bot/reminders',
diff --git a/bot/log.py b/bot/log.py
index b3cecdcf2..100cd06f6 100644
--- a/bot/log.py
+++ b/bot/log.py
@@ -48,16 +48,17 @@ def setup() -> None:
logging.addLevelName(TRACE_LEVEL, "TRACE")
logging.setLoggerClass(CustomLogger)
+ root_log = get_logger()
+
format_string = "%(asctime)s | %(name)s | %(levelname)s | %(message)s"
log_format = logging.Formatter(format_string)
- log_file = Path("logs", "bot.log")
- log_file.parent.mkdir(exist_ok=True)
- file_handler = handlers.RotatingFileHandler(log_file, maxBytes=5242880, backupCount=7, encoding="utf8")
- file_handler.setFormatter(log_format)
-
- root_log = get_logger()
- root_log.addHandler(file_handler)
+ if constants.FILE_LOGS:
+ log_file = Path("logs", "bot.log")
+ log_file.parent.mkdir(exist_ok=True)
+ file_handler = handlers.RotatingFileHandler(log_file, maxBytes=5242880, backupCount=7, encoding="utf8")
+ file_handler.setFormatter(log_format)
+ root_log.addHandler(file_handler)
if "COLOREDLOGS_LEVEL_STYLES" not in os.environ:
coloredlogs.DEFAULT_LEVEL_STYLES = {
diff --git a/bot/resources/tags/off-topic.md b/bot/resources/tags/off-topic.md
index 6a864a1d5..287224d7f 100644
--- a/bot/resources/tags/off-topic.md
+++ b/bot/resources/tags/off-topic.md
@@ -1,9 +1,9 @@
**Off-topic channels**
There are three off-topic channels:
-• <#291284109232308226>
-• <#463035241142026251>
• <#463035268514185226>
+• <#463035241142026251>
+• <#291284109232308226>
Their names change randomly every 24 hours, but you can always find them under the `OFF-TOPIC/GENERAL` category in the channel list.
diff --git a/bot/utils/members.py b/bot/utils/members.py
index 77ddf1696..693286045 100644
--- a/bot/utils/members.py
+++ b/bot/utils/members.py
@@ -23,3 +23,26 @@ async def get_or_fetch_member(guild: discord.Guild, member_id: int) -> t.Optiona
return None
log.trace("%s fetched from API.", member)
return member
+
+
+async def handle_role_change(
+ member: discord.Member,
+ coro: t.Callable[..., t.Coroutine],
+ role: discord.Role
+) -> None:
+ """
+ Change `member`'s cooldown role via awaiting `coro` and handle errors.
+
+ `coro` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`.
+ """
+ try:
+ await coro(role)
+ except discord.NotFound:
+ log.debug(f"Failed to change role for {member} ({member.id}): member not found")
+ except discord.Forbidden:
+ log.debug(
+ f"Forbidden to change role for {member} ({member.id}); "
+ f"possibly due to role hierarchy"
+ )
+ except discord.HTTPException as e:
+ log.error(f"Failed to change role for {member} ({member.id}): {e.status} {e.code}")
diff --git a/config-default.yml b/config-default.yml
index 4a85ccc56..0d3ddc005 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -1,4 +1,5 @@
-debug: !ENV ["BOT_DEBUG", "true"]
+debug: !ENV ["BOT_DEBUG", "true"]
+file_logs: !ENV ["FILE_LOGS", "false"]
bot:
@@ -263,7 +264,12 @@ guild:
- *BLACK_FORMATTER
roles:
+ # Self-assignable roles, see the Subscribe cog
+ advent_of_code: 518565788744024082
announcements: 463658397560995840
+ lovefest: 542431903886606399
+ pyweek_announcements: 897568414044938310
+
contributors: 295488872404484098
help_cooldown: 699189276025421825
muted: &MUTED_ROLE 277914926603829249
@@ -307,6 +313,7 @@ guild:
big_brother: 569133704568373283
dev_log: 680501655111729222
duck_pond: 637821475327311927
+ incidents: 816650601844572212
incidents_archive: 720671599790915702
python_news: &PYNEWS_WEBHOOK 704381182279942324
diff --git a/docker-compose.yml b/docker-compose.yml
index b3ca6baa4..869d9acb6 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -90,7 +90,6 @@ services:
context: .
dockerfile: Dockerfile
volumes:
- - ./logs:/bot/logs
- .:/bot:ro
tty: true
depends_on:
diff --git a/tests/bot/exts/backend/test_error_handler.py b/tests/bot/exts/backend/test_error_handler.py
index 462f718e6..d12329b1f 100644
--- a/tests/bot/exts/backend/test_error_handler.py
+++ b/tests/bot/exts/backend/test_error_handler.py
@@ -572,38 +572,6 @@ class IndividualErrorHandlerTests(unittest.IsolatedAsyncioTestCase):
push_scope_mock.set_extra.has_calls(set_extra_calls)
-class OtherErrorHandlerTests(unittest.IsolatedAsyncioTestCase):
- """Other `ErrorHandler` tests."""
-
- def setUp(self):
- self.bot = MockBot()
- self.ctx = MockContext()
-
- async def test_get_help_command_command_specified(self):
- """Should return coroutine of help command of specified command."""
- self.ctx.command = "foo"
- result = ErrorHandler.get_help_command(self.ctx)
- expected = self.ctx.send_help("foo")
- self.assertEqual(result.__qualname__, expected.__qualname__)
- self.assertEqual(result.cr_frame.f_locals, expected.cr_frame.f_locals)
-
- # Await coroutines to avoid warnings
- await result
- await expected
-
- async def test_get_help_command_no_command_specified(self):
- """Should return coroutine of help command."""
- self.ctx.command = None
- result = ErrorHandler.get_help_command(self.ctx)
- expected = self.ctx.send_help()
- self.assertEqual(result.__qualname__, expected.__qualname__)
- self.assertEqual(result.cr_frame.f_locals, expected.cr_frame.f_locals)
-
- # Await coroutines to avoid warnings
- await result
- await expected
-
-
class ErrorHandlerSetupTests(unittest.TestCase):
"""Tests for `ErrorHandler` `setup` function."""
diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py
index ccc842050..cfe0c4b03 100644
--- a/tests/bot/exts/moderation/test_incidents.py
+++ b/tests/bot/exts/moderation/test_incidents.py
@@ -3,13 +3,16 @@ import enum
import logging
import typing as t
import unittest
+from unittest import mock
from unittest.mock import AsyncMock, MagicMock, Mock, call, patch
import aiohttp
import discord
+from async_rediscache import RedisSession
from bot.constants import Colours
from bot.exts.moderation import incidents
+from bot.utils.messages import format_user
from tests.helpers import (
MockAsyncWebhook, MockAttachment, MockBot, MockMember, MockMessage, MockReaction, MockRole, MockTextChannel,
MockUser
@@ -276,6 +279,22 @@ class TestIncidents(unittest.IsolatedAsyncioTestCase):
the instance as they wish.
"""
+ session = None
+
+ async def flush(self):
+ """Flush everything from the database to prevent carry-overs between tests."""
+ with await self.session.pool as connection:
+ await connection.flushall()
+
+ async def asyncSetUp(self): # noqa: N802
+ self.session = RedisSession(use_fakeredis=True)
+ await self.session.connect()
+ await self.flush()
+
+ async def asyncTearDown(self): # noqa: N802
+ if self.session:
+ await self.session.close()
+
def setUp(self):
"""
Prepare a fresh `Incidents` instance for each test.
@@ -506,7 +525,7 @@ class TestProcessEvent(TestIncidents):
with patch("bot.exts.moderation.incidents.Incidents.make_confirmation_task", mock_task):
await self.cog_instance.process_event(
reaction=incidents.Signal.ACTIONED.value,
- incident=MockMessage(),
+ incident=MockMessage(id=123),
member=MockMember(roles=[MockRole(id=1)])
)
@@ -526,7 +545,7 @@ class TestProcessEvent(TestIncidents):
with patch("bot.exts.moderation.incidents.Incidents.make_confirmation_task", mock_task):
await self.cog_instance.process_event(
reaction=incidents.Signal.ACTIONED.value,
- incident=MockMessage(),
+ incident=MockMessage(id=123),
member=MockMember(roles=[MockRole(id=1)])
)
except asyncio.TimeoutError:
@@ -761,3 +780,74 @@ class TestOnMessage(TestIncidents):
await self.cog_instance.on_message(MockMessage())
mock_add_signals.assert_not_called()
+
+
+class TestMessageLinkEmbeds(TestIncidents):
+ """Tests for `extract_message_links` coroutine."""
+
+ async def test_shorten_text(self):
+ """Test all cases of text shortening by mocking messages."""
+ tests = {
+ "thisisasingleword"*10: "thisisasinglewordthisisasinglewordthisisasinglewor...",
+
+ "\n".join("Lets make a new line test".split()): "Lets\nmake\na...",
+
+ 'Hello, World!' * 300: (
+ "Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!"
+ "Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!"
+ "Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!"
+ "Hello, World!Hello, World!H..."
+ )
+ }
+
+ for content, expected_conversion in tests.items():
+ with self.subTest(content=content, expected_conversion=expected_conversion):
+ conversion = incidents.shorten_text(content)
+ self.assertEqual(conversion, expected_conversion)
+
+ async def extract_and_form_message_link_embeds(self):
+ """
+ Extract message links from a mocked message and form the message link embed.
+
+ Considers all types of message links, discord supports.
+ """
+ self.guild_id_patcher = mock.patch("bot.exts.backend.sync._cog.constants.Guild.id", 5)
+ self.guild_id = self.guild_id_patcher.start()
+
+ msg = MockMessage(id=555, content="Hello, World!" * 3000)
+ msg.channel.mention = "#lemonade-stand"
+
+ msg_links = [
+ # Valid Message links
+ f"https://discord.com/channels/{self.guild_id}/{msg.channel.discord_id}/{msg.discord_id}",
+ f"http://canary.discord.com/channels/{self.guild_id}/{msg.channel.discord_id}/{msg.discord_id}",
+
+ # Invalid Message links
+ f"https://discord.com/channels/{msg.channel.discord_id}/{msg.discord_id}",
+ f"https://discord.com/channels/{self.guild_id}/{msg.channel.discord_id}000/{msg.discord_id}",
+ ]
+
+ incident_msg = MockMessage(
+ id=777,
+ content=(
+ f"I would like to report the following messages, "
+ f"as they break our rules: \n{', '.join(msg_links)}"
+ )
+ )
+
+ with patch(
+ "bot.exts.moderation.incidents.Incidents.extract_message_links", AsyncMock()
+ ) as mock_extract_message_links:
+ embeds = mock_extract_message_links(incident_msg)
+ description = (
+ f"**Author:** {format_user(msg.author)}\n"
+ f"**Channel:** {msg.channel.mention} ({msg.channel.category}/#{msg.channel.name})\n"
+ f"**Content:** {('Hello, World!' * 3000)[:300] + '...'}\n"
+ )
+
+ # Check number of embeds returned with number of valid links
+ self.assertEqual(len(embeds), 2)
+
+ # Check for the embed descriptions
+ for embed in embeds:
+ self.assertEqual(embed.description, description)