diff options
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) |