Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
80 commits
Select commit Hold shift + click to select a range
3f3be73
Start working on auto report
MattyTheHacker Mar 24, 2026
732d837
Implement automatic reporting message deletion to committee
MattyTheHacker Mar 24, 2026
d0e0daa
Add extra checks
MattyTheHacker Mar 24, 2026
0342b81
Merge branch 'main' into add-deleted-message-log
MattyTheHacker Mar 24, 2026
b7b8a6e
Fixes
MattyTheHacker Mar 24, 2026
48bb5c2
Fix
MattyTheHacker Mar 24, 2026
f6b49d6
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Mar 25, 2026
a665e47
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Mar 26, 2026
63b2a31
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Mar 27, 2026
48948f6
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Mar 30, 2026
b8e51db
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Mar 31, 2026
6ce5df2
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Apr 2, 2026
5f6f015
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Apr 6, 2026
943abf7
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Apr 7, 2026
b214836
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Apr 9, 2026
876fc8c
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Apr 13, 2026
707be9e
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Apr 15, 2026
152f9dc
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Apr 16, 2026
bbfbf39
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Apr 16, 2026
f13b11a
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Apr 16, 2026
73f8523
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Apr 19, 2026
0ad1a7c
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Apr 20, 2026
fa202f8
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Apr 22, 2026
0d09c66
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Apr 27, 2026
a9de56a
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Apr 28, 2026
2d13c05
Merge main into add-deleted-message-log
automatic-pr-updater[bot] May 1, 2026
50d3f28
Merge main into add-deleted-message-log
automatic-pr-updater[bot] May 3, 2026
c0d3e4a
Merge main into add-deleted-message-log
automatic-pr-updater[bot] May 4, 2026
1e957ef
Merge main into add-deleted-message-log
automatic-pr-updater[bot] May 5, 2026
f3016a1
Merge main into add-deleted-message-log
automatic-pr-updater[bot] May 6, 2026
6ca9fec
Merge main into add-deleted-message-log
automatic-pr-updater[bot] May 6, 2026
e8d50e0
Merge main into add-deleted-message-log
automatic-pr-updater[bot] May 9, 2026
d17c544
Merge main into add-deleted-message-log
automatic-pr-updater[bot] May 12, 2026
40b7b17
Merge main into add-deleted-message-log
automatic-pr-updater[bot] May 12, 2026
9ba4986
Merge main into add-deleted-message-log
automatic-pr-updater[bot] May 12, 2026
9320681
Merge main into add-deleted-message-log
automatic-pr-updater[bot] May 18, 2026
6d18afd
Merge main into add-deleted-message-log
automatic-pr-updater[bot] May 19, 2026
1d94930
Merge main into add-deleted-message-log
automatic-pr-updater[bot] May 23, 2026
f14d974
Merge main into add-deleted-message-log
automatic-pr-updater[bot] May 25, 2026
cf8cc26
Merge main into add-deleted-message-log
automatic-pr-updater[bot] May 25, 2026
d011bac
Merge main into add-deleted-message-log
automatic-pr-updater[bot] May 26, 2026
e5dad0b
Merge main into add-deleted-message-log
automatic-pr-updater[bot] May 27, 2026
97c417f
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Jun 3, 2026
f647817
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Jun 3, 2026
476e893
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Jun 4, 2026
5144801
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Jun 7, 2026
7ab748e
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Jun 8, 2026
a603e75
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Jun 10, 2026
3d6bb01
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Jun 17, 2026
b820156
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Jun 18, 2026
254b7d8
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Jun 21, 2026
fab8661
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Jun 23, 2026
834aec9
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Jun 24, 2026
97d6a1d
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Jun 29, 2026
0eebf8f
Remove duplication
MattyTheHacker Jun 29, 2026
10436d5
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Jun 29, 2026
ca37327
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Jun 30, 2026
66dedc9
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Jul 1, 2026
47f7152
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Jul 1, 2026
74b81c0
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Jul 2, 2026
4dc7598
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Jul 3, 2026
9bca953
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Jul 4, 2026
9b4cdbd
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Jul 4, 2026
da788f1
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Jul 5, 2026
e6f1a11
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Jul 10, 2026
2da32da
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Jul 13, 2026
88126b5
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Jul 17, 2026
2453244
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Jul 20, 2026
e4e4ce2
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Jul 20, 2026
4d3f29d
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Aug 1, 2026
e3ea2f0
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Aug 1, 2026
612b37b
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Aug 1, 2026
878c928
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Aug 1, 2026
fd9590b
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Aug 6, 2026
463bed1
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Aug 9, 2026
e83ba04
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Aug 9, 2026
b3a6dfe
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Aug 22, 2026
f819ae6
Merge main into add-deleted-message-log
automatic-pr-updater[bot] Aug 22, 2026
467d22f
Improvements
MattyTheHacker Aug 23, 2026
2ecbf36
Improvements
MattyTheHacker Aug 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions cogs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -77,6 +78,7 @@
"MakeMemberCommandCog",
"ManualModerationCog",
"MemberCountCommandCog",
"MessageDeletionTrackingCog",
"PingCommandCog",
"RemindMeCommandCog",
"SendGetRolesRemindersTaskCog",
Expand Down Expand Up @@ -118,6 +120,7 @@ def setup(bot: "TeXBot") -> None:
MakeMemberCommandCog,
ManualModerationCog,
MemberCountCommandCog,
MessageDeletionTrackingCog,
PingCommandCog,
RemindMeCommandCog,
SendGetRolesRemindersTaskCog,
Expand Down
191 changes: 191 additions & 0 deletions cogs/message_deletion_tracking.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 4 additions & 0 deletions cogs/startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
GuestRoleDoesNotExistError,
GuildDoesNotExistError,
MemberRoleDoesNotExistError,
MessageReportsChannelDoesNotExistError,
MSLMembershipError,
RolesChannelDoesNotExistError,
)
Expand Down Expand Up @@ -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:
Expand Down
111 changes: 26 additions & 85 deletions cogs/strike.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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,
)
Loading