diff --git a/README.md b/README.md index 2183c532..43f8d5ac 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,9 @@ The meaning of each error code is given here: * `E1032` - Your [Discord guild](https://discord.com/developers/docs/resources/guild) does not contain a [text channel](https://docs.pycord.dev/en/stable/api/models.html#discord.TextChannel) with the name "#**general**". (This [text channel](https://docs.pycord.dev/en/stable/api/models.html#discord.TextChannel) is required for the `/induct` [command](https://discord.com/developers/docs/interactions/application-commands)) +* `E1033` - Your [Discord guild](https://discord.com/developers/docs/resources/guild) does not contain a [text channel](https://docs.pycord.dev/en/stable/api/models.html#discord.TextChannel) with the name "#**discord**". +(This [text channel](https://docs.pycord.dev/en/stable/api/models.html#discord.TextChannel) is required for the "Report Message to Committee" & "Strike Message Author" [context menu commands](https://discord.com/developers/docs/interactions/application-commands#message-commands), and for reporting messages that were deleted by a moderator) + * `E1041` - The community group member IDs could not be retrieved from the SU platform. (It is likely that your `SU_PLATFORM_ACCESS_COOKIE` is invalid. If your community group is a [Guild of Students](https://guildofstudents.com) [society](https://wikipedia.org/wiki/Student_society), the community group member IDs will be a list of [UoB IDs](https://intranet.birmingham.ac.uk/campus-services/id-cards.aspx)) @@ -149,6 +152,13 @@ A full guide on how to create your bot's account can be found [here; on Pycord's You'll need to create a [Discord bot](https://discord.com/developers/docs/topics/oauth2#bot-vs-user-accounts) of your own in the [Discord Developer Portal](https://discord.com/developers/applications). It's also handy if you have an empty [Discord guild](https://discord.com/developers/docs/resources/guild) for you to test in. +TeX-Bot requires two [privileged gateway intents](https://discord.com/developers/docs/events/gateway#privileged-intents) to be enabled on your bot's page in the [Discord Developer Portal](https://discord.com/developers/applications), under "Bot" > "Privileged Gateway Intents": + +* **Server Members Intent**: used to look up the members of your [Discord guild](https://discord.com/developers/docs/resources/guild), which almost every command depends upon +* **Message Content Intent**: used to read the content of messages, so that a copy of any message deleted by a moderator can be retained for committee to review + +TeX-Bot will fail to start, with a `PrivilegedIntentsRequired` error, until both of these have been enabled. + The correct [invite URL](https://docs.pycord.dev/en/stable/discord.html#inviting-your-bot) will be displayed to you in the console the first time you run the bot (or if you set a high verbosity log level) ### Setting [Environment Variables](https://wikipedia.org/wiki/Environment_variable) diff --git a/cogs/__init__.py b/cogs/__init__.py index 0c74de61..2bf35207 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 .message_deletion_tracking import MessageDeletionTrackingCog from .ping import PingCommandCog from .remind_me import ClearRemindersBacklogTaskCog, RemindMeCommandCog from .send_get_roles_reminders import SendGetRolesRemindersTaskCog @@ -77,6 +78,7 @@ "MakeMemberCommandCog", "ManualModerationCog", "MemberCountCommandCog", + "MessageDeletionTrackingCog", "PingCommandCog", "RemindMeCommandCog", "SendGetRolesRemindersTaskCog", @@ -118,6 +120,7 @@ def setup(bot: "TeXBot") -> None: MakeMemberCommandCog, ManualModerationCog, MemberCountCommandCog, + MessageDeletionTrackingCog, PingCommandCog, RemindMeCommandCog, SendGetRolesRemindersTaskCog, diff --git a/cogs/message_deletion_tracking.py b/cogs/message_deletion_tracking.py new file mode 100644 index 00000000..b013bf71 --- /dev/null +++ b/cogs/message_deletion_tracking.py @@ -0,0 +1,191 @@ +"""Contains cog classes for tracking messages deleted by moderators.""" + +import asyncio +import datetime +import logging +from collections import deque +from typing import TYPE_CHECKING, NamedTuple, override + +import discord + +from exceptions import MessageReportsChannelDoesNotExistError +from utils import MessageReportAction, TeXBotBaseCog, send_message_report_to_committee +from utils.error_capture_decorators import capture_guild_does_not_exist_error + +if TYPE_CHECKING: + from collections.abc import Sequence + from collections.abc import Set as AbstractSet + from logging import Logger + from typing import Final + + from utils import TeXBot + + +__all__: "Sequence[str]" = ("MessageDeletionTrackingCog",) + + +logger: "Final[Logger]" = logging.getLogger("TeX-Bot") + + +class _PendingDeletedMessage(NamedTuple): + """A deleted message, awaiting the audit-log entry that names who deleted it.""" + + deleted_at: datetime.datetime + message: discord.Message + + +class MessageDeletionTrackingCog(TeXBotBaseCog): + """ + Cog class defining the event listeners for reporting moderator-deleted messages. + + This exists to catch the case where a committee member deletes somebody else's message + but forgets to run the "Report Message to Committee" command beforehand: a copy of the + deleted message is sent to the message-reports channel, so that it is still retained. + + Only individual deletions are tracked. Bulk deletions (channel purges) are deliberately + not covered, as those are not the single forgotten-report case this exists to catch. + + Discord sends the content of a deleted message (in the message-delete gateway event) + separately from who deleted it (in the audit-log-entry gateway event), + so deleted messages are retained until their matching audit-log entry arrives. + Messages that users delete themselves never gain an audit-log entry, + so those are simply discarded once they expire. + """ + + # NOTE: Deletions are held only for the moment between the two gateway events that describe them, so this bound just prevents unbounded growth if audit-log entries stop arriving. Expiry is what normally empties the store + MAXIMUM_PENDING_DELETED_MESSAGES: "Final[int]" = 25 + PENDING_DELETED_MESSAGE_EXPIRY: "Final[datetime.timedelta]" = datetime.timedelta( + seconds=30 + ) + AUDIT_LOG_ENTRY_GRACE_PERIOD: "Final[float]" = 2.0 + + @override + def __init__(self, bot: "TeXBot") -> None: + """Initialise the store of deleted messages awaiting their audit-log entry.""" + self._pending_deleted_messages: deque[_PendingDeletedMessage] = deque( + maxlen=self.MAXIMUM_PENDING_DELETED_MESSAGES + ) + + super().__init__(bot) + + def _take_pending_deleted_messages( + self, *, author_id: int, channel_id: int, count: int + ) -> "Sequence[discord.Message]": + """ + Remove & return the retained deleted messages matching the given audit-log entry. + + At most `count` messages are returned, taking the most recently deleted matches: + the audit-log entry states how many deletions it covers, so any older match is left + pending rather than being wrongly attributed to this entry. A message that its own + author deleted never gains an audit-log entry, so is only ever discarded on expiry. + + Any retained messages that have been waiting for an audit-log entry for longer than + `PENDING_DELETED_MESSAGE_EXPIRY` are discarded. + """ + EXPIRY_CUTOFF: Final[datetime.datetime] = ( + discord.utils.utcnow() - self.PENDING_DELETED_MESSAGE_EXPIRY + ) + + unexpired_messages: Sequence[_PendingDeletedMessage] = [ + pending_deleted_message + for pending_deleted_message in self._pending_deleted_messages + if pending_deleted_message.deleted_at >= EXPIRY_CUTOFF + ] + + if count < 1: + self._pending_deleted_messages = deque( + unexpired_messages, maxlen=self.MAXIMUM_PENDING_DELETED_MESSAGES + ) + return () + + MATCHED_INDEXES: Final[Sequence[int]] = [ + index + for index, pending_deleted_message in enumerate(unexpired_messages) + if pending_deleted_message.message.channel.id == channel_id + and pending_deleted_message.message.author.id == author_id + ] + TAKEN_INDEXES: Final[AbstractSet[int]] = frozenset(MATCHED_INDEXES[-count:]) + + self._pending_deleted_messages = deque( + ( + pending_deleted_message + for index, pending_deleted_message in enumerate(unexpired_messages) + if index not in TAKEN_INDEXES + ), + maxlen=self.MAXIMUM_PENDING_DELETED_MESSAGES, + ) + + return [unexpired_messages[index].message for index in sorted(TAKEN_INDEXES)] + + async def _report_deleted_messages( + self, + deleted_messages: "Sequence[discord.Message]", + deleter: discord.User | discord.Member, + ) -> None: + """Send a copy of each of the given deleted messages to the message-reports channel.""" + committee_role: discord.Role = await self.bot.committee_role + + message_reports_channel_error: MessageReportsChannelDoesNotExistError + try: + deleted_message: discord.Message + for deleted_message in deleted_messages: + SELF_DELETED: bool = deleted_message.author == deleter + # NOTE: Committee members deleting one another's messages is treated as housekeeping rather than as a moderation action, so is never reported + AUTHORED_BY_COMMITTEE: bool = ( + isinstance(deleted_message.author, discord.Member) + and committee_role in deleted_message.author.roles + ) + + if SELF_DELETED or AUTHORED_BY_COMMITTEE: + continue + + await send_message_report_to_committee( + self.bot, + message=deleted_message, + reporting_user=deleter, + action=MessageReportAction.DELETED, + ) + except MessageReportsChannelDoesNotExistError as message_reports_channel_error: + logger.error( # noqa: TRY400 + "Could not report deleted messages to committee: %s", + message_reports_channel_error.message, + ) + + @TeXBotBaseCog.listener() + @capture_guild_does_not_exist_error + async def on_message_delete(self, message: discord.Message) -> None: + """Retain a deleted message until its audit-log entry names who deleted it.""" + if message.author.bot or message.guild != self.bot.main_guild: + return + + self._pending_deleted_messages.append( + _PendingDeletedMessage(deleted_at=discord.utils.utcnow(), message=message) + ) + + @TeXBotBaseCog.listener() + @capture_guild_does_not_exist_error + async def on_audit_log_entry(self, entry: discord.AuditLogEntry) -> None: + """Report any retained messages that the given audit-log entry says were deleted.""" + # NOTE: The action is filtered before any shortcut accessors are used, so that entries of every other action type (role updates, kicks, bans, etc.) do not repeatedly hit the guild & role accessors + if entry.action is not discord.AuditLogAction.message_delete: + return + + if not isinstance(entry.target, (discord.Member, discord.User)): + return + + deleter: discord.User | discord.Member | None = entry.user + if deleter is None or deleter == self.bot.user: + return + + # NOTE: Discord does not guarantee that the message-delete gateway event arrives before the audit-log entry describing it, so a short grace period is given for it to catch up. This also allows a single audit-log entry to collect a whole burst of rapid deletions, which Discord aggregates into that one entry + await asyncio.sleep(self.AUDIT_LOG_ENTRY_GRACE_PERIOD) + + deleted_messages: Sequence[discord.Message] = self._take_pending_deleted_messages( + author_id=entry.target.id, + # NOTE: `extra.channel` is a bare `discord.Object` whenever the deleted message was sent within a thread, so only the channel's ID can be relied upon here + channel_id=entry.extra.channel.id, # type: ignore[union-attr] + count=entry.extra.count, # type: ignore[union-attr] + ) + + if deleted_messages: + await self._report_deleted_messages(deleted_messages, deleter) diff --git a/cogs/startup.py b/cogs/startup.py index c19028d4..a0d1bd4e 100644 --- a/cogs/startup.py +++ b/cogs/startup.py @@ -15,6 +15,7 @@ GuestRoleDoesNotExistError, GuildDoesNotExistError, MemberRoleDoesNotExistError, + MessageReportsChannelDoesNotExistError, MSLMembershipError, RolesChannelDoesNotExistError, ) @@ -111,6 +112,9 @@ async def on_ready(self) -> None: if not discord.utils.get(main_guild.text_channels, name="general"): logger.warning(GeneralChannelDoesNotExistError()) + if not discord.utils.get(main_guild.text_channels, name="discord"): + logger.warning(MessageReportsChannelDoesNotExistError()) + try: await fetch_community_group_members_list() except MSLMembershipError as msl_membership_error: diff --git a/cogs/strike.py b/cogs/strike.py index 46fc40ec..bbbea989 100644 --- a/cogs/strike.py +++ b/cogs/strike.py @@ -18,7 +18,12 @@ NoAuditLogsStrikeTrackingError, StrikeTrackingError, ) -from utils import CommandChecks, TeXBotBaseCog +from utils import ( + CommandChecks, + MessageReportAction, + TeXBotBaseCog, + send_message_report_to_committee, +) from utils.error_capture_decorators import ( capture_guild_does_not_exist_error, capture_strike_tracking_error, @@ -992,86 +997,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 +1016,32 @@ 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 send_message_report_to_committee( + self.bot, + message=message, + reporting_user=ctx.user, + action=MessageReportAction.REPORTED, + ) 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) + # NOTE: A missing message-reports channel raises MessageReportsChannelDoesNotExistError, which the global command-error handler reports back to the user + await send_message_report_to_committee( + self.bot, + message=message, + reporting_user=ctx.user, + action=MessageReportAction.REPORTED, + ) + + await ctx.respond( + content=":white_check_mark: Successfully reported message to committee channels!", + ephemeral=True, + ) diff --git a/exceptions/__init__.py b/exceptions/__init__.py index dc3c44ce..c2c12406 100644 --- a/exceptions/__init__.py +++ b/exceptions/__init__.py @@ -14,6 +14,7 @@ GuestRoleDoesNotExistError, GuildDoesNotExistError, MemberRoleDoesNotExistError, + MessageReportsChannelDoesNotExistError, RoleDoesNotExistError, RolesChannelDoesNotExistError, RulesChannelDoesNotExistError, @@ -47,6 +48,7 @@ "InvalidMessagesJSONFileError", "MSLMembershipError", "MemberRoleDoesNotExistError", + "MessageReportsChannelDoesNotExistError", "MessagesJSONFileMissingKeyError", "MessagesJSONFileValueError", "NoAuditLogsStrikeTrackingError", diff --git a/exceptions/does_not_exist.py b/exceptions/does_not_exist.py index 97ab355c..ea30c753 100644 --- a/exceptions/does_not_exist.py +++ b/exceptions/does_not_exist.py @@ -21,6 +21,7 @@ "GuestRoleDoesNotExistError", "GuildDoesNotExistError", "MemberRoleDoesNotExistError", + "MessageReportsChannelDoesNotExistError", "RoleDoesNotExistError", "RolesChannelDoesNotExistError", "RulesChannelDoesNotExistError", @@ -304,3 +305,17 @@ def DEPENDENT_COMMANDS(cls) -> frozenset[str]: @override def CHANNEL_NAME(cls) -> str: return "general" + + +class MessageReportsChannelDoesNotExistError(ChannelDoesNotExistError): + """Exception class to raise when the message-reports Discord channel is missing.""" + + @classproperty + @override + def ERROR_CODE(cls) -> str: + return "E1033" + + @classproperty + @override + def CHANNEL_NAME(cls) -> str: + return "discord" # TODO: Make this user-configurable # noqa: FIX002 diff --git a/main.py b/main.py index 548f1e3c..a913f537 100755 --- a/main.py +++ b/main.py @@ -25,8 +25,11 @@ with SuppressTraceback(): config.run_setup() + # NOTE: Both the members & message-content intents are privileged, so must also be enabled on your bot's page in the Discord Developer Portal (see the "Creating Your Bot" section of the README). The message-content intent is required to retain the content of moderator-deleted messages 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/__init__.py b/utils/__init__.py index 48ce5285..b59f3d1a 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -8,6 +8,7 @@ import discord from .command_checks import CommandChecks +from .message_reports import MessageReportAction, send_message_report_to_committee from .message_sender_components import MessageSavingSenderComponent from .suppress_traceback import SuppressTraceback from .tex_bot import TeXBot @@ -22,6 +23,7 @@ "GLOBAL_SSL_CONTEXT", "AllChannelTypes", "CommandChecks", + "MessageReportAction", "MessageSavingSenderComponent", "SuppressTraceback", "TeXBot", @@ -31,6 +33,7 @@ "generate_invite_url", "is_member_inducted", "is_running_in_async", + "send_message_report_to_committee", ) diff --git a/utils/message_reports.py b/utils/message_reports.py new file mode 100644 index 00000000..2adc7abc --- /dev/null +++ b/utils/message_reports.py @@ -0,0 +1,104 @@ +"""Utility functions for sending copies of Discord messages to committee for review.""" + +from enum import Enum +from typing import TYPE_CHECKING + +import discord + +if TYPE_CHECKING: + from collections.abc import Sequence + from typing import Final + + from .tex_bot import TeXBot + + +__all__: "Sequence[str]" = ("MessageReportAction", "send_message_report_to_committee") + + +MAXIMUM_REPORTED_CONTENT_LENGTH: "Final[int]" = 600 + + +class MessageReportAction(Enum): + """Enum class to define the reason a message was sent to committee for review.""" + + DELETED = "deleted" + REPORTED = "reported" + + +def _format_report_description(message: discord.Message, action: MessageReportAction) -> str: + """Construct the embed description that holds the reported message's content.""" + description: str + + if message.content: + description = message.content[:MAXIMUM_REPORTED_CONTENT_LENGTH] + if len(message.content) > MAXIMUM_REPORTED_CONTENT_LENGTH: + description += ( + f" _... (truncated to {MAXIMUM_REPORTED_CONTENT_LENGTH} characters)_" + ) + else: + description = f"_{action.value.capitalize()} message had no content_" + if message.attachments or message.embeds: + description += " _but did have one or more attachments!_" + + description += f"\n[View Original]({message.jump_url})" + + if message.reference: + description += f"\n[View Message this replied to]({message.reference.jump_url})" + + return description + + +def _get_report_image_url(message: discord.Message) -> str | None: + """Retrieve the URL of the given message's only attachment, if it is an image.""" + if len(message.attachments) != 1: + return None + + attachment_type: str | None = message.attachments[0].content_type + + if not attachment_type or "image" not in attachment_type: + return None + + return message.attachments[0].url + + +async def send_message_report_to_committee( + bot: "TeXBot", + message: discord.Message, + reporting_user: discord.User | discord.Member, + action: MessageReportAction, +) -> None: + """ + Send a copy of the given message to the message-reports channel, for committee to review. + + Raises `MessageReportsChannelDoesNotExist` if that channel does not exist. + """ + message_reports_channel: discord.TextChannel = await bot.message_reports_channel + + await message_reports_channel.send( + content=( + f"{reporting_user.mention} {action.value} " + f"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=discord.EmbedAuthor( + name=message.author.display_name, icon_url=message.author.display_avatar.url + ), + description=_format_report_description(message, action), + colour=message.author.colour, + image=_get_report_image_url(message), + timestamp=message.created_at, + ), + ) diff --git a/utils/tex_bot.py b/utils/tex_bot.py index f86749fd..a9de2b6d 100644 --- a/utils/tex_bot.py +++ b/utils/tex_bot.py @@ -21,6 +21,7 @@ GuestRoleDoesNotExistError, GuildDoesNotExistError, MemberRoleDoesNotExistError, + MessageReportsChannelDoesNotExistError, RoleDoesNotExistError, RolesChannelDoesNotExistError, RulesChannelDoesNotExistError, @@ -61,6 +62,7 @@ def __init__(self, *args: object, **options: object) -> None: # noqa: CAR150 self._roles_channel: discord.TextChannel | None = None self._general_channel: discord.TextChannel | None = None self._rules_channel: discord.TextChannel | None = None + self._message_reports_channel: discord.TextChannel | None = None self._exit_was_due_to_kill_command: bool = False self._main_guild_set: bool = False @@ -280,6 +282,28 @@ async def rules_channel(self) -> discord.TextChannel: return self._rules_channel + @property + async def message_reports_channel(self) -> discord.TextChannel: + """ + Shortcut accessor to the message-reports text channel. + + The message-reports text channel is the one that copies of reported + & moderator-deleted messages are sent to, for committee to review. + + Raises `MessageReportsChannelDoesNotExist` if the channel does not exist. + """ + if not self._message_reports_channel or not self._main_guild_has_channel( + self._message_reports_channel + ): + self._message_reports_channel = await self._fetch_main_guild_text_channel( + "discord" # TODO: Make this user-configurable # noqa: FIX002 + ) + + if not self._message_reports_channel: + raise MessageReportsChannelDoesNotExistError + + return self._message_reports_channel + @property def group_full_name(self) -> str: """