diff --git a/cogs/__init__.py b/cogs/__init__.py index 0c74de61e..51afaf0a2 100644 --- a/cogs/__init__.py +++ b/cogs/__init__.py @@ -36,6 +36,7 @@ from .kill import KillCommandCog from .make_applicant import MakeApplicantContextCommandsCog, MakeApplicantSlashCommandCog from .make_member import MakeMemberCommandCog, MemberCountCommandCog +from .moderation import ModerationCog from .ping import PingCommandCog from .remind_me import ClearRemindersBacklogTaskCog, RemindMeCommandCog from .send_get_roles_reminders import SendGetRolesRemindersTaskCog @@ -77,6 +78,7 @@ "MakeMemberCommandCog", "ManualModerationCog", "MemberCountCommandCog", + "ModerationCog", "PingCommandCog", "RemindMeCommandCog", "SendGetRolesRemindersTaskCog", @@ -118,6 +120,7 @@ def setup(bot: "TeXBot") -> None: MakeMemberCommandCog, ManualModerationCog, MemberCountCommandCog, + ModerationCog, PingCommandCog, RemindMeCommandCog, SendGetRolesRemindersTaskCog, diff --git a/cogs/moderation.py b/cogs/moderation.py new file mode 100644 index 000000000..10c121654 --- /dev/null +++ b/cogs/moderation.py @@ -0,0 +1,68 @@ +"""File to hold cog classes related to moderation actions and tracking.""" + +import logging +from typing import TYPE_CHECKING + +import discord + +from utils import TeXBotBaseCog +from utils.error_capture_decorators import capture_guild_does_not_exist_error + +if TYPE_CHECKING: + from collections.abc import Sequence + from logging import Logger + from typing import Final + + +__all__: "Sequence[str]" = ("ModerationCog",) + + +logger: "Final[Logger]" = logging.getLogger("TeX-Bot") + + +class ModerationCog(TeXBotBaseCog): + """Cog to track moderation actions and report them to the committee.""" + + most_recently_deleted_message: discord.Message | None = None + + @TeXBotBaseCog.listener() + async def on_message_delete(self, message: discord.Message) -> None: + """Listen for message deletions.""" + if message.guild is None or message.author.bot or message.guild != self.bot.main_guild: + return + + self.most_recently_deleted_message = message + + @TeXBotBaseCog.listener() + @capture_guild_does_not_exist_error + async def on_audit_log_entry(self, entry: discord.AuditLogEntry) -> None: + """Listen for audit log entries.""" + committee_role: discord.Role = await self.bot.committee_role + + if ( + entry.action != discord.AuditLogAction.message_delete + or not self.most_recently_deleted_message + ): + return + + deleter: discord.Member | discord.User | None = entry.user + author: discord.User | discord.Member | None = entry.target + channel: discord.TextChannel | None = entry.extra.channel # type: ignore[union-attr] + + if ( + not isinstance(channel, discord.TextChannel) + or not isinstance(author, discord.Member) + or not isinstance(deleter, discord.Member) + ): + return + + if ( + author != self.most_recently_deleted_message.author + or channel != self.most_recently_deleted_message.channel + or committee_role in author.roles + ): + return + + await self.send_message_to_committee(self.most_recently_deleted_message, deleter) + + self.most_recently_deleted_message = None diff --git a/cogs/strike.py b/cogs/strike.py index 46fc40ec2..b8ba4ec0c 100644 --- a/cogs/strike.py +++ b/cogs/strike.py @@ -992,86 +992,6 @@ async def decrement_strikes( class StrikeContextCommandsCog(BaseStrikeCog): """Cog class that defines the context menu strike command and its call-back method.""" - async def _send_message_to_committee( - self, ctx: "TeXBotApplicationContext", message: discord.Message - ) -> None: - """Send a provided message to committee channels.""" - discord_channel: discord.TextChannel | None = discord.utils.get( - self.bot.main_guild.text_channels, - name="discord", # TODO: Make this user-configurable # noqa: FIX002 - ) - - if not discord_channel: - await self.command_send_error( - ctx, message="Could not find the `#discord` channel in the main guild!" - ) - return - - if not message.guild: - await self.command_send_error( - ctx, message="Message supplied did not have a guild ID!" - ) - return - - embed_content: str = "" - - if message.content: - embed_content += message.content[:600] - if len(message.content) > 600: - embed_content += " _... (truncated to 600 characters)_" - else: - embed_content += "_Reported message had no content_" - if len(message.attachments) > 0 or len(message.embeds) > 0: - embed_content += " _but did have one or more attachments!_" - - embed_content += f"\n[View Original]({message.jump_url})" - - if message.reference: - embed_content += f"\n[View Message this replied to]({message.reference.jump_url})" - - message_author_avatar_url: str | None = message.author.display_avatar.url - - embed_author: discord.EmbedAuthor = discord.EmbedAuthor( - name=message.author.display_name, icon_url=message_author_avatar_url - ) - - embed_image: str | None = None - if len(message.attachments) == 1: - attachment_type: str | None = message.attachments[0].content_type - if attachment_type and "image" in attachment_type: - embed_image = message.attachments[0].url - - await discord_channel.send( - content=( - f"{ctx.user.mention} reported a message from {message.author.mention} " - f"in { - message.channel.mention - if isinstance( - message.channel, - ( - discord.TextChannel, - discord.VoiceChannel, - discord.StageChannel, - discord.Thread, - ), - ) - else message.channel - }:" - ), - embed=discord.Embed( - author=embed_author, - description=embed_content, - colour=message.author.colour, - image=embed_image, - timestamp=message.created_at, - ), - ) - - await ctx.respond( - content=":white_check_mark: Successfully reported message to committee channels!", - ephemeral=True, - ) - @discord.user_command(name="Strike User") @CommandChecks.check_interaction_user_has_committee_role @CommandChecks.check_interaction_user_in_main_guild @@ -1091,16 +1011,16 @@ async def strike_message_author( strike_user: discord.Member = await self.bot.get_member_from_str_id( str(message.author.id) ) - await self._send_message_to_committee(ctx, message=message) + await self.send_message_to_committee(message=message, interaction_user=ctx.user) await self._command_perform_strike(ctx, strike_member=strike_user) @discord.message_command( - name="Send Message to Committee", + name="Report Message to Committee", description="Sends the selected message to the committee channel for discussion.", ) @CommandChecks.check_interaction_user_in_main_guild - async def send_message_to_committee( + async def report_message_to_committee( self, ctx: "TeXBotApplicationContext", message: discord.Message ) -> None: """Send a copy of the selected message to committee channels for review.""" - await self._send_message_to_committee(ctx, message=message) + await self.send_message_to_committee(message=message, interaction_user=ctx.user) diff --git a/main.py b/main.py index 548f1e3cd..f6c865bab 100755 --- a/main.py +++ b/main.py @@ -26,7 +26,9 @@ config.run_setup() bot: TeXBot = TeXBot( - intents=discord.Intents.default() | discord.Intents.members + intents=discord.Intents.default() + | discord.Intents.members + | discord.Intents.message_content ) # NOTE: See https://github.com/CSSUoB/TeX-Bot-Py-V2/issues/261 bot.load_extension("cogs") diff --git a/utils/tex_bot_base_cog.py b/utils/tex_bot_base_cog.py index e18c06e9a..309842ee0 100644 --- a/utils/tex_bot_base_cog.py +++ b/utils/tex_bot_base_cog.py @@ -197,3 +197,69 @@ async def autocomplete_get_text_channels( discord.Permissions(send_messages=True, view_channel=True), ) } + + async def send_message_to_committee( + self, message: discord.Message, interaction_user: discord.Member + ) -> None: + """Send a copy of the given message to the committee channel for review.""" + discord_channel: discord.TextChannel | None = discord.utils.get( + self.bot.main_guild.text_channels, + name="discord", # TODO: Make this user-configurable # noqa: FIX002 + ) + + if not discord_channel: + logger.error("Could not find the channel to send the message deletion report to!") + return + + embed_content: str = "" + + if message.content: + embed_content += message.content[:600] + if len(message.content) > 600: + embed_content += " _... (truncated to 600 characters)_" + else: + embed_content += "_Deleted message had no content_" + if len(message.attachments) > 0 or len(message.embeds) > 0: + embed_content += " _but did have one or more attachments!_" + + embed_content += f"\n[View Original]({message.jump_url})" + + if message.reference: + embed_content += f"\n[View Message this replied to]({message.reference.jump_url})" + + message_author_avatar_url: str | None = message.author.display_avatar.url + + embed_author: discord.EmbedAuthor = discord.EmbedAuthor( + name=message.author.display_name, icon_url=message_author_avatar_url + ) + + embed_image: str | None = None + if len(message.attachments) == 1: + attachment_type: str | None = message.attachments[0].content_type + if attachment_type and "image" in attachment_type: + embed_image = message.attachments[0].url + + await discord_channel.send( + content=( + f"{interaction_user.mention} deleted a message from {message.author.mention} " + f"in { + message.channel.mention + if isinstance( + message.channel, + ( + discord.TextChannel, + discord.VoiceChannel, + discord.StageChannel, + discord.Thread, + ), + ) + else message.channel + }:" + ), + embed=discord.Embed( + author=embed_author, + description=embed_content, + colour=message.author.colour, + image=embed_image, + ), + )