Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
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 .moderation import ModerationCog
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",
"ModerationCog",
"PingCommandCog",
"RemindMeCommandCog",
"SendGetRolesRemindersTaskCog",
Expand Down Expand Up @@ -118,6 +120,7 @@ def setup(bot: "TeXBot") -> None:
MakeMemberCommandCog,
ManualModerationCog,
MemberCountCommandCog,
ModerationCog,
PingCommandCog,
RemindMeCommandCog,
SendGetRolesRemindersTaskCog,
Expand Down
133 changes: 133 additions & 0 deletions cogs/moderation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""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

async def _send_message_to_committee(
self, message: discord.Message, deleter: discord.Member
) -> None:
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"{deleter.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,
),
)

Comment on lines +23 to +92
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_send_message_to_committee is effectively duplicated from StrikeContextCommandsCog._send_message_to_committee (same embed construction, truncation rules, attachment handling, etc.). Having two copies makes future changes easy to miss and risks inconsistent committee reporting behavior; consider extracting this into a shared helper (e.g. a util function or a method on TeXBotBaseCog) and reusing it from both cogs.

Suggested change
class ModerationCog(TeXBotBaseCog):
"""Cog to track moderation actions and report them to the committee."""
most_recently_deleted_message: discord.Message | None = None
async def _send_message_to_committee(
self, message: discord.Message, deleter: discord.Member
) -> None:
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"{deleter.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,
),
)
async def _send_message_deletion_to_committee(
bot: discord.Client, message: discord.Message, deleter: discord.Member
) -> None:
discord_channel: discord.TextChannel | None = discord.utils.get(
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"{deleter.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,
),
)
class ModerationCog(TeXBotBaseCog):
"""Cog to track moderation actions and report them to the committee."""
most_recently_deleted_message: discord.Message | None = None
async def _send_message_to_committee(
self, message: discord.Message, deleter: discord.Member
) -> None:
await _send_message_deletion_to_committee(self.bot, message, deleter)

Copilot uses AI. Check for mistakes.
@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

Comment on lines +93 to +100
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

most_recently_deleted_message only tracks a single deletion and is never cleared/expired. If a user deletes their own message (no audit-log entry), this cached message can later be incorrectly paired with a moderator message_delete audit-log entry for the same author/channel, causing the bot to report the wrong content (and miss the real deleted message). Consider storing a short-lived queue keyed by (channel_id, author_id) with deletion timestamps (and clearing on successful match), rather than a single global reference.

Copilot uses AI. Check for mistakes.
@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
4 changes: 3 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the biggest issue here. We have purposefully limited the privileges of TeX-Bot to not have access to read members messages and this is a departure from this. Making this change requires a vote of approval from CSS committee.

) # NOTE: See https://github.com/CSSUoB/TeX-Bot-Py-V2/issues/261

bot.load_extension("cogs")
Expand Down
Loading