From 3c642b00fc1b3d20f57a76191f0a3b6cb7912fa7 Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Sat, 13 Jun 2026 17:06:41 -0700 Subject: [PATCH 1/6] Redoes modlog database logging --- core/databases.py | 25 +- core/moderation.py | 61 ---- modules/moderation/automod.py | 48 +++- modules/moderation/moderator.py | 136 ++++----- modules/moderation/modlog.py | 480 +++++++++++++++++++++++-------- modules/moderation/modmail.py | 16 +- modules/moderation/notes.py | 19 ++ modules/moderation/purge.py | 14 +- modules/moderation/report.py | 8 + modules/operation/application.py | 19 ++ 10 files changed, 564 insertions(+), 262 deletions(-) diff --git a/core/databases.py b/core/databases.py index 57bdc22a..ce844f31 100644 --- a/core/databases.py +++ b/core/databases.py @@ -63,7 +63,7 @@ class ApplicationBans(bot.db.Model): guild_id: str = bot.db.Column(bot.db.String) applicant_id: str = bot.db.Column(bot.db.String) - class BanLog(bot.db.Model): + class ModLog(bot.db.Model): """The postgres table for banlogs Currently used in modlog.py @@ -77,14 +77,25 @@ class BanLog(bot.db.Model): ban_time (datetime): The date and time of the ban """ - __tablename__ = "banlog" + __tablename__ = "modlog" + __table_args__ = (bot.db.UniqueConstraint("guild_id", "guild_case_id"),) pk = bot.db.Column(bot.db.Integer, primary_key=True, autoincrement=True) guild_id = bot.db.Column(bot.db.String) - reason = bot.db.Column(bot.db.String) - banning_moderator = bot.db.Column(bot.db.String) - banned_member = bot.db.Column(bot.db.String) - ban_time = bot.db.Column(bot.db.DateTime, default=datetime.datetime.utcnow) + guild_case_id = bot.db.Column(bot.db.Integer) + action = bot.db.Column(bot.db.String) + reason = bot.db.Column(bot.db.String, default="") + data = bot.db.Column(bot.db.String, default="") + moderator_id = bot.db.Column(bot.db.String, default="") + member_id = bot.db.Column(bot.db.String, default="") + action_time = bot.db.Column( + bot.db.DateTime(timezone=True), + default=lambda: datetime.datetime.now(datetime.UTC), + ) + until_time = bot.db.Column( + bot.db.DateTime(timezone=True), + nullable=True, + ) class DuckUser(bot.db.Model): """The postgres table for ducks @@ -368,7 +379,7 @@ class XP(bot.db.Model): bot.models.Applications = Applications bot.models.AppBans = ApplicationBans - bot.models.BanLog = BanLog + bot.models.ModLog = ModLog bot.models.DuckUser = DuckUser bot.models.Factoid = Factoid bot.models.FactoidJob = FactoidJob diff --git a/core/moderation.py b/core/moderation.py index 6649acdf..0c10e907 100644 --- a/core/moderation.py +++ b/core/moderation.py @@ -203,67 +203,6 @@ async def get_all_notes( return user_notes -async def send_command_usage_alert( - bot_object: object, - interaction: discord.Interaction, - command: str, - guild: discord.Guild, - target: discord.Member = None, -) -> None: - """Sends a usage alert to the protect events channel, if configured - - Args: - bot_object (object): The bot object to use - interaction (discord.Interaction): The interaction that trigger the command - command (str): The string representation of the command that was run - guild (discord.Guild): The guild the command was run in - target (discord.Member): The target of the command - """ - - ALERT_ICON_URL: str = ( - "https://www.iconarchive.com/download/i76061/martz90/circle-addon2/warning.512.png" - ) - - try: - alert_channel = guild.get_channel( - int(configuration.get_config_entry(guild.id, "moderation_alert_channel")) - ) - except TypeError: - alert_channel = None - - if not alert_channel: - return - - embed = discord.Embed(title="Command Usage Alert") - - embed.description = f"**Command**\n`{command}`" - embed.add_field( - name="Channel", - value=f"{interaction.channel.name} ({interaction.channel.mention}) [Jump to context]" - f"(https://discord.com/channels/{interaction.guild.id}/{interaction.channel.id}/" - f"{discord.utils.time_snowflake(datetime.datetime.utcnow())})", - ) - - embed.add_field( - name="Invoking User", - value=( - f"{interaction.user.display_name} ({interaction.user.mention}, {interaction.user.id})" - ), - ) - - if target: - embed.add_field( - name="Target", - value=f"{target.display_name} ({target.mention}, {target.id})", - ) - - embed.set_thumbnail(url=ALERT_ICON_URL) - embed.color = discord.Color.red() - embed.timestamp = datetime.datetime.utcnow() - - await alert_channel.send(embed=embed) - - async def check_if_user_banned(user: discord.User, guild: discord.Guild) -> bool: """Queries the given guild to find if the given discord.User is banned or not diff --git a/modules/moderation/automod.py b/modules/moderation/automod.py index 8875bf7e..9b16c128 100644 --- a/modules/moderation/automod.py +++ b/modules/moderation/automod.py @@ -156,12 +156,28 @@ async def response( total_punishment = process_automod_violations(all_punishments=all_punishments) + logged = False + if total_punishment.mute > 0: + expires_at = datetime.datetime.now(datetime.UTC) + datetime.timedelta( + seconds=total_punishment.mute_duration + ) + await moderation.mute_user( user=ctx.author, reason=total_punishment.violation_string, duration=datetime.timedelta(seconds=total_punishment.mute_duration), ) + await modlog.log_action( + bot=self.bot, + action_type="timeout", + guild=ctx.guild, + member=ctx.author, + reason=total_punishment.violation_string, + expires_at=expires_at, + data=f"Violating message: {ctx.message.clean_content[:100]}", + ) + logged = True if total_punishment.delete_message: await ctx.message.delete() @@ -180,6 +196,15 @@ async def response( ctx.channel.guild.me, total_punishment.violation_string, ) + await modlog.log_action( + bot=self.bot, + action_type="warn", + guild=ctx.guild, + member=ctx.author, + reason=total_punishment.violation_string, + data=f"Violating message: {ctx.message.clean_content[:100]}", + ) + logged = True max_warnings = configuration.get_config_entry( ctx.guild.id, "moderation_max_warnings" ) @@ -215,14 +240,25 @@ async def response( ), reason=total_punishment.violation_string, ) - await modlog.log_ban( - self.bot, - ctx.author, - ctx.guild.me, - ctx.guild, - total_punishment.violation_string, + await modlog.log_action( + bot=self.bot, + action_type="ban", + guild=ctx.guild, + member=ctx.author, + reason=total_punishment.violation_string, + data=f"Violating message: {ctx.message.clean_content[:100]}", ) + if not logged: + await modlog.log_action( + bot=self.bot, + action_type="automod notice", + guild=ctx.guild, + member=ctx.author, + reason=total_punishment.violation_string, + data=f"Violating message: {ctx.message.clean_content[:100]}", + ) + if total_punishment.be_silent: return diff --git a/modules/moderation/moderator.py b/modules/moderation/moderator.py index f0e90df2..39311635 100644 --- a/modules/moderation/moderator.py +++ b/modules/moderation/moderator.py @@ -106,20 +106,15 @@ async def handle_ban_user( await interaction.response.send_message(embed=embed) return - await modlog.log_ban( - self.bot, target, interaction.user, interaction.guild, reason - ) - - await moderation.send_command_usage_alert( - bot_object=self.bot, - interaction=interaction, - command=( - f"/ban target: {target.display_name}, reason: {reason}, delete_days:" - f" {delete_days}" - ), + await modlog.log_action( + bot=self.bot, + action_type="ban", guild=interaction.guild, - target=target, + member=target, + moderator=interaction.user, + reason=reason, ) + embed = generate_response_embed(user=target, action="ban", reason=reason) await interaction.response.send_message(content=target.mention, embed=embed) @@ -173,17 +168,15 @@ async def handle_unban_user( await interaction.response.send_message(embed=embed) return - await modlog.log_unban( - self.bot, target, interaction.user, interaction.guild, reason - ) - - await moderation.send_command_usage_alert( - bot_object=self.bot, - interaction=interaction, - command=f"/unban target: {target.display_name}, reason: {reason}", + await modlog.log_action( + bot=self.bot, + action_type="unban", guild=interaction.guild, - target=target, + member=target, + moderator=interaction.user, + reason=reason, ) + embed = generate_response_embed(user=target, action="unban", reason=reason) await interaction.response.send_message(content=target.mention, embed=embed) @@ -233,13 +226,15 @@ async def handle_kick_user( await interaction.response.send_message(embed=embed) return - await moderation.send_command_usage_alert( - bot_object=self.bot, - interaction=interaction, - command=f"/kick target: {target.display_name}, reason: {reason}", + await modlog.log_action( + bot=self.bot, + action_type="kick", guild=interaction.guild, - target=target, + member=target, + moderator=interaction.user, + reason=reason, ) + embed = generate_response_embed(user=target, action="kick", reason=reason) await interaction.response.send_message(content=target.mention, embed=embed) @@ -324,11 +319,21 @@ async def handle_mute_user( await interaction.response.send_message(embed=embed) return + expires_at = datetime.datetime.now(datetime.UTC) + delta_duration result = await moderation.mute_user( user=target, reason=f"{reason} - muted by {interaction.user}", duration=delta_duration, ) + await modlog.log_action( + bot=self.bot, + action_type="timeout", + guild=interaction.guild, + member=target, + moderator=interaction.user, + reason=reason, + expires_at=expires_at, + ) if not result: embed = auxiliary.prepare_deny_embed( message=f"Something went wrong when muting {target}" @@ -336,17 +341,6 @@ async def handle_mute_user( await interaction.response.send_message(embed=embed) return - await moderation.send_command_usage_alert( - bot_object=self.bot, - interaction=interaction, - command=( - f"/mute target: {target.display_name}, reason: {reason}, duration:" - f" {duration}" - ), - guild=interaction.guild, - target=target, - ) - muted_until_timestamp = ( f"" ) @@ -401,6 +395,14 @@ async def handle_unmute_user( user=target, reason=f"{reason} - unmuted by {interaction.user}", ) + await modlog.log_action( + bot=self.bot, + action_type="untimeout", + guild=interaction.guild, + member=target, + moderator=interaction.user, + reason=reason, + ) if not result: embed = auxiliary.prepare_deny_embed( message=f"Something went wrong when unmuting {target}" @@ -408,13 +410,6 @@ async def handle_unmute_user( await interaction.response.send_message(embed=embed) return - await moderation.send_command_usage_alert( - bot_object=self.bot, - interaction=interaction, - command=f"/unmute target: {target.display_name}, reason: {reason}", - guild=interaction.guild, - target=target, - ) embed = generate_response_embed(user=target, action="unmute", reason=reason) await interaction.response.send_message(content=target.mention, embed=embed) @@ -486,6 +481,14 @@ async def handle_warn_user( warn_result = await moderation.warn_user( bot_object=self.bot, user=target, invoker=interaction.user, reason=reason ) + await modlog.log_action( + bot=self.bot, + action_type="warn", + guild=interaction.guild, + member=target, + moderator=interaction.user, + reason=reason, + ) if should_ban: ban_result = await moderation.ban_user( @@ -512,8 +515,14 @@ async def handle_warn_user( else: await interaction.response.send_message(embed=embed) return - await modlog.log_ban( - self.bot, target, interaction.user, interaction.guild, reason + + await modlog.log_action( + bot=self.bot, + action_type="ban", + guild=interaction.guild, + member=target, + moderator=interaction.user, + reason=reason, ) if not warn_result: @@ -526,14 +535,6 @@ async def handle_warn_user( await interaction.response.send_message(embed=embed) return - await moderation.send_command_usage_alert( - bot_object=self.bot, - interaction=interaction, - command=f"/warn target: {target.display_name}, reason: {reason}", - guild=interaction.guild, - target=target, - ) - response_action = "warn" if should_ban: response_action = response_action + " + ban" @@ -610,6 +611,14 @@ async def handle_unwarn_user( result = await moderation.unwarn_user( bot_object=self.bot, user=target, warning=warning ) + await modlog.log_action( + bot=self.bot, + action_type="unwarn", + guild=interaction.guild, + member=target, + moderator=interaction.user, + reason=reason, + ) if not result: embed = auxiliary.prepare_deny_embed( message=f"Something went wrong when unwarning {target}" @@ -617,13 +626,6 @@ async def handle_unwarn_user( await interaction.response.send_message(embed=embed) return - await moderation.send_command_usage_alert( - bot_object=self.bot, - interaction=interaction, - command=f"/unwarn target: {target.display_name}, reason: {reason}, warning: {warning}", - guild=interaction.guild, - target=target, - ) embed = generate_response_embed(user=target, action="unwarn", reason=reason) await interaction.response.send_message(content=target.mention, embed=embed) @@ -710,12 +712,14 @@ async def handle_warning_clear( for warning in warnings: await moderation.unwarn_user(self.bot, target, warning.reason) - await moderation.send_command_usage_alert( - bot_object=self.bot, - interaction=interaction, - command=f"/warnings clear target: {target.display_name}, reaason: {reason}", + await modlog.log_action( + bot=self.bot, + action_type="clear warn", guild=interaction.guild, - target=target, + member=target, + moderator=interaction.user, + reason=reason, + data=f"Total warnings: {len(warnings)}", ) embed = generate_response_embed( diff --git a/modules/moderation/modlog.py b/modules/moderation/modlog.py index 127bc0fa..045ccc7f 100644 --- a/modules/moderation/modlog.py +++ b/modules/moderation/modlog.py @@ -2,10 +2,12 @@ from __future__ import annotations +import asyncio import datetime from collections import Counter from typing import TYPE_CHECKING, Self +import asyncpg import discord import munch from discord import app_commands @@ -25,10 +27,10 @@ async def setup(bot: bot.TechSupportBot) -> None: Args: bot (bot.TechSupportBot): The bot object to register the cog with """ - await bot.add_cog(BanLogger(bot=bot)) + await bot.add_cog(ModLogger(bot=bot)) -class BanLogger(cogs.BaseCog): +class ModLogger(cogs.BaseCog): """The class that holds the /modlog commands Attributes: @@ -191,164 +193,413 @@ async def convert_ban_to_pretty_string( return embed @commands.Cog.listener() - async def on_member_ban( - self: Self, guild: discord.Guild, user: discord.User | discord.Member + async def on_audit_log_entry_create( + self: Self, entry: discord.AuditLogEntry ) -> None: - """See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_member_ban + """This is monitoring for certain events that otherwise cannot be tracked + Tracks: Kicks, (Un)bans, (Un)timeouts, (Un)mutes, (Un)deafs Args: - guild (discord.Guild): The guild the user got banned from - user (discord.User | discord.Member): The user that got banned. Can be either User - or Member depending if the user was in the guild or not at the time of removal. + entry (discord.AuditLogEntry): The audit log event """ - # Wait a short time to ensure the audit log has been updated - await discord.utils.sleep_until( - discord.utils.utcnow() + datetime.timedelta(seconds=2) - ) - - if not self.extension_enabled(guild): + if not self.extension_enabled(entry.guild): return - entry = None - moderator = None - async for entry in guild.audit_logs( - limit=10, action=discord.AuditLogAction.ban - ): - if entry.target.id == user.id: - moderator = entry.user - break + if entry.action == discord.AuditLogAction.kick: + moderator = await self.bot.fetch_user(entry.user_id) - if not entry: - return + if not moderator or moderator.bot: + return - if not moderator or moderator.bot: - return + await log_action( + bot=self.bot, + action_type="kick", + guild=entry.guild, + member=entry.target, + moderator=moderator, + reason=entry.reason, + ) + elif entry.action == discord.AuditLogAction.ban: + moderator = await self.bot.fetch_user(entry.user_id) + + if not moderator or moderator.bot: + return + + await log_action( + bot=self.bot, + action_type="ban", + guild=entry.guild, + member=entry.target, + moderator=moderator, + reason=entry.reason, + ) + elif entry.action == discord.AuditLogAction.unban: + moderator = await self.bot.fetch_user(entry.user_id) + + if not moderator or moderator.bot: + return + + await log_action( + bot=self.bot, + action_type="unban", + guild=entry.guild, + member=entry.target, + moderator=moderator, + reason=entry.reason, + ) + elif entry.action == discord.AuditLogAction.member_update: + # Since discord throws a ton of actions into member update, we must monitor it + # We are looking for time out, server deaf/mute + # This is NOT for native automod actions. Because why be even slightly consistent + moderator = await self.bot.fetch_user(entry.user_id) + + print(moderator) + + if not moderator or moderator.bot: + return + + before_timeout = getattr(entry.before, "timed_out_until", None) + after_timeout = getattr(entry.after, "timed_out_until", None) + before_deaf = getattr(entry.before, "deaf", None) + after_deaf = getattr(entry.after, "deaf", None) + before_mute = getattr(entry.before, "mute", None) + after_mute = getattr(entry.after, "mute", None) + + # Mute + if after_timeout: + await log_action( + bot=self.bot, + action_type="timeout", + guild=entry.guild, + member=entry.target, + moderator=moderator, + reason=entry.reason, + expires_at=entry.after.timed_out_until, + ) + elif before_timeout and not after_timeout: + await log_action( + bot=self.bot, + action_type="untimeout", + guild=entry.guild, + member=entry.target, + moderator=moderator, + reason=entry.reason, + ) - await log_ban(self.bot, user, moderator, guild, entry.reason) + # Server Deafened + if after_deaf and not before_deaf: + await log_action( + bot=self.bot, + action_type="deaf", + guild=entry.guild, + member=entry.target, + moderator=moderator, + reason=entry.reason, + ) + elif before_deaf and not after_deaf: + await log_action( + bot=self.bot, + action_type="undeaf", + guild=entry.guild, + member=entry.target, + moderator=moderator, + reason=entry.reason, + ) + + # Server Muted + if after_mute and not before_mute: + await log_action( + bot=self.bot, + action_type="mute", + guild=entry.guild, + member=entry.target, + moderator=moderator, + reason=entry.reason, + ) + elif before_mute and not after_mute: + await log_action( + bot=self.bot, + action_type="unmute", + guild=entry.guild, + member=entry.target, + moderator=moderator, + reason=entry.reason, + ) @commands.Cog.listener() - async def on_member_unban( - self: Self, guild: discord.Guild, user: discord.User - ) -> None: - """See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_member_unban + async def on_automod_action(self: Self, execution: discord.AutoModAction) -> None: + """This monitors native auto mod executions + This must log everything native automod does + since native automod does't trigger other logs + + This is called for every individual action taken. So blocking, + alerting and muting will call this 3 times. Args: - guild (discord.Guild): The guild the user got unbanned from - user (discord.User): The user that got unbanned + execution (discord.AutoModAction): The action that automod has taken """ - # Wait a short time to ensure the audit log has been updated - await discord.utils.sleep_until( - discord.utils.utcnow() + datetime.timedelta(seconds=2) - ) + # I hate everything about this + rule = await execution.fetch_rule() + member = await self.bot.fetch_user(execution.user_id) + if any( + action.type == discord.AutoModRuleActionType.timeout + for action in rule.actions + ): + if execution.action.type == discord.AutoModRuleActionType.timeout: + expires_at = ( + datetime.datetime.now(datetime.UTC) + execution.action.duration + ) + await log_action( + bot=self.bot, + action_type="timeout", + guild=rule.guild, + member=member, + reason=rule.name, + expires_at=expires_at, + data=f"Violating content: {execution.content[:200]}", + ) + elif any( + action.type == discord.AutoModRuleActionType.block_member_interactions + for action in rule.actions + ): + if ( + execution.action.type + == discord.AutoModRuleActionType.block_member_interactions + ): + await log_action( + bot=self.bot, + action_type="Quarantine", + guild=rule.guild, + member=member, + reason=rule.name, + data=f"Violating name: {member.display_name}", + ) + elif any( + action.type == discord.AutoModRuleActionType.block_message + for action in rule.actions + ): + if execution.action.type == discord.AutoModRuleActionType.block_message: + await log_action( + bot=self.bot, + action_type="automod block", + guild=rule.guild, + member=member, + reason=rule.name, + data=f"Violating name: {member.display_name}", + ) + else: + await log_action( + bot=self.bot, + action_type="automod notice", + guild=rule.guild, + member=member, + reason=rule.name, + data=f"Violating content: {execution.content[:200]}", + ) - if not self.extension_enabled(guild): + @commands.Cog.listener() + async def on_member_join(self: Self, member: discord.Member) -> None: + """This is to track unkicks, aka people joining the server back after a recent kick + + Args: + member (discord.Member): The member who has joined the server + """ + if not self.extension_enabled(member.guild): return - entry = None - moderator = None - async for entry in guild.audit_logs( - limit=10, action=discord.AuditLogAction.unban - ): - if entry.target.id == user.id: - moderator = entry.user - if not entry: + most_recent_kick = ( + await self.bot.models.ModLog.query.where( + self.bot.models.ModLog.guild_id == str(member.guild.id) + ) + .where(self.bot.models.ModLog.member_id == str(member.id)) + .where(self.bot.models.ModLog.action == "kick") + .order_by(self.bot.models.ModLog.guild_case_id.desc()) + .gino.first() + ) + + # No kick on record, nothing to do + if most_recent_kick is None: return - if not moderator or moderator.bot: + most_recent_unkick = ( + await self.bot.models.ModLog.query.where( + self.bot.models.ModLog.guild_id == str(member.guild.id) + ) + .where(self.bot.models.ModLog.member_id == str(member.id)) + .where(self.bot.models.ModLog.action == "unkick") + .order_by(self.bot.models.ModLog.guild_case_id.desc()) + .gino.first() + ) + + # If they've never been marked as unkicked, this join is an unkick + if most_recent_unkick is None: + await log_action( + bot=self.bot, + action_type="unkick", + guild=member.guild, + member=member, + ) return - await log_unban(self.bot, user, moderator, guild, entry.reason) + # If the latest kick is newer than the latest unkick, + # this join corresponds to that kick + if most_recent_kick.guild_case_id > most_recent_unkick.guild_case_id: + await log_action( + bot=self.bot, + action_type="unkick", + guild=member.guild, + member=member, + ) -# Any bans initiated by TS will come through this -async def log_ban( - bot: bot.TechSupportBot, - banned_member: discord.User | discord.Member, - banning_moderator: discord.Member, - guild: discord.Guild, - reason: str, -) -> None: - """Logs a ban into the alert channel +async def generate_action_embed( + bot: bot.models.ModLog, action_entry: munch.Munch +) -> discord.Embed: + """This generates a stylized embed that displays the mod action taken and information Args: - bot (bot.TechSupportBot): The bot object to use for the logging - banned_member (discord.User | discord.Member): The member who was banned - banning_moderator (discord.Member): The moderator who banned the member - guild (discord.Guild): The guild the member was banned from - reason (str): The reason for the ban - """ - if "moderation.modlog" not in configuration.get_config_entry( - guild.id, "core_enabled_extensions" - ): - return + bot (bot.models.ModLog): The bot object, used to fetch information from discord + action_entry (munch.Munch): The action entry from the database to display - if not reason: - reason = "No reason specified" + Returns: + discord.Embed: The final embed, ready to display + """ - ban = bot.models.BanLog( - guild_id=str(guild.id), - reason=reason, - banning_moderator=str(banning_moderator.id), - banned_member=str(banned_member.id), + embed = discord.Embed( + title=f"Case {action_entry.guild_case_id} | {action_entry.action.title()}" ) - ban = await ban.create() + description_strs = [] - embed = discord.Embed(title=f"ban | case {ban.pk}") - embed.description = ( - f"**Offender:** {banned_member.name} {banned_member.mention}\n" - f"**Reason:** {reason}\n" - f"**Responsible moderator:** {banning_moderator.name} {banning_moderator.mention}" - ) - embed.set_footer(text=f"ID: {banned_member.id}") - embed.timestamp = datetime.datetime.utcnow() - embed.color = discord.Color.red() + # If this action has a member punished, display if + if action_entry.member_id: + member_account = await bot.fetch_user(int(action_entry.member_id)) + description_strs.append( + f"**Offender:** {member_account.name} {member_account.mention} ({member_account.id})" + ) - try: - alert_channel = guild.get_channel( - int(configuration.get_config_entry(guild.id, "modlog_alert_channel")) + # Always display the reason line, just if no reason explicilty display that + if action_entry.reason: + description_strs.append(f"**Reason:** {action_entry.reason}") + else: + description_strs.append("**Reason:** No reason specified") + + # If a moderator has been tied to this action, display that + # If no moderator has been listed, this must be automod + if action_entry.moderator_id: + moderator_account = await bot.fetch_user(int(action_entry.moderator_id)) + description_strs.append( + f"**Responsible moderator:** {moderator_account.name} {moderator_account.mention} ({moderator_account.id})" + ) + else: + description_strs.append("**Responsible moderator:** No associated moderator") + + # If this action has extra data, display it as is. + if action_entry.data: + # This might need special handling for specific events, we will see + description_strs.append(f"**Data:** {action_entry.data}") + + # If this action has an expiration date, display it + if action_entry.until_time: + description_strs.append( + f"**Until:** " ) - except TypeError: - alert_channel = None - if not alert_channel: - return + embed.description = "\n".join(description_strs) + embed.timestamp = action_entry.action_time + if "un" in action_entry.action: + embed.color = discord.Color.green() + elif "clear" in action_entry.action: + embed.color = discord.Color.blue() + else: + embed.color = discord.Color.red() - await alert_channel.send(embed=embed) + return embed + + +async def get_next_case_number( + bot: bot.TechSupportBot, + guild: discord.Guild, +) -> int: + """This searches the database for all cases in the given guild + This will return the next case number to use for a given guild, or 1 if no cases exist + + Args: + bot (bot.TechSupportBot): The bot object, used to fetch information from the database + guild (discord.Guild): The guild to get the next number for + + Returns: + int: The next case number to use + """ + latest_case = ( + await bot.models.ModLog.query.where(bot.models.ModLog.guild_id == str(guild.id)) + .order_by(bot.models.ModLog.guild_case_id.desc()) + .gino.first() + ) + + if latest_case is None: + return 1 + return latest_case.guild_case_id + 1 -async def log_unban( + +async def log_action( bot: bot.TechSupportBot, - unbanned_member: discord.User | discord.Member, - unbanning_moderator: discord.Member, + action_type: str, guild: discord.Guild, - reason: str, + member: discord.abc.User | None = None, + moderator: discord.abc.User | None = None, + reason: str = "", + data: str = "", + expires_at: datetime.datetime = None, ) -> None: - """Logs an unban into the alert channel + """This logs a mod action in the database, and sends it to the modlog channel Args: - bot (bot.TechSupportBot): The bot object to use for the logging - unbanned_member (discord.User | discord.Member): The member who was unbanned - unbanning_moderator (discord.Member): The moderator who unbanned the member - guild (discord.Guild): The guild the member was unbanned from - reason (str): The reason for the unban + bot (bot.TechSupportBot): The bot object, used to access the database + action_type (str): The action type as a string representation + guild (discord.Guild): The guild this action occured in + member (discord.abc.User | None, optional): The member being punished, if applicable. + moderator (discord.abc.User | None, optional): The moderator taking action, if applicable. + reason (str, optional): The reason this action was taken, if applicable. + data (str, optional): Any extra data associated with this action, if applicable. + expires_at (datetime.datetime, optional): When this action expires, if applicable. """ + # Log nothing if the module is disabled if "moderation.modlog" not in configuration.get_config_entry( guild.id, "core_enabled_extensions" ): return - if not reason: - reason = "No reason specified" - - embed = discord.Embed(title="unban") - embed.description = ( - f"**Offender:** {unbanned_member.name} {unbanned_member.mention}\n" - f"**Reason:** {reason}\n" - f"**Responsible moderator:** {unbanning_moderator.name} {unbanning_moderator.mention}" - ) - embed.set_footer(text=f"ID: {unbanned_member.id}") - embed.timestamp = datetime.datetime.utcnow() - embed.color = discord.Color.green() + # If the action has a member or moderator, get the IDs + member_id = None + moderator_id = None + if member: + member_id = str(member.id) + if moderator: + moderator_id = str(moderator.id) + + # This is done to prevent race conditions where two actions get the same case number + # We can detect the unique error and just try again. If so, this means + # A new database entry was created after we got the case number + while True: + try: + # We need to calculate the case ID number for this action + case_id = await get_next_case_number(bot, guild) + + entry = bot.models.ModLog( + guild_id=str(guild.id), + guild_case_id=case_id, + action=action_type, + reason=reason, + data=data, + moderator_id=moderator_id, + member_id=member_id, + until_time=expires_at, + ) + entry = await entry.create() + break + except asyncpg.UniqueViolationError: + continue try: alert_channel = guild.get_channel( @@ -360,4 +611,5 @@ async def log_unban( if not alert_channel: return + embed = await generate_action_embed(bot, entry) await alert_channel.send(embed=embed) diff --git a/modules/moderation/modmail.py b/modules/moderation/modmail.py index 9eefb4c9..ea9d283d 100644 --- a/modules/moderation/modmail.py +++ b/modules/moderation/modmail.py @@ -26,7 +26,7 @@ import configuration import ui from core import auxiliary, cogs -from modules.moderation import rules +from modules.moderation import modlog, rules if TYPE_CHECKING: import bot @@ -997,6 +997,13 @@ async def modmail_ban( case ui.ConfirmResponse.CONFIRMED: await self.bot.models.ModmailBan(user_id=str(user.id)).create() + await modlog.log_action( + bot=self.bot, + action_type="modmail ban", + guild=interaction.guild, + member=user, + moderator=interaction.user, + ) embed = auxiliary.prepare_confirm_embed( message=f"{user.mention} was successfully banned from creating future modmail" @@ -1227,6 +1234,13 @@ async def modmail_unban( return await ban_entry.delete() + await modlog.log_action( + bot=self.bot, + action_type="modmail unban", + guild=interaction.guild, + member=user, + moderator=interaction.user, + ) embed = auxiliary.prepare_confirm_embed( message=f"{user.mention} was successfully unbanned from creating modmail threads!", diff --git a/modules/moderation/notes.py b/modules/moderation/notes.py index 23ac7a98..1ac68a71 100644 --- a/modules/moderation/notes.py +++ b/modules/moderation/notes.py @@ -12,6 +12,7 @@ import ui from botlogging import LogContext, LogLevel from core import auxiliary, cogs, moderation +from modules.moderation import modlog if TYPE_CHECKING: import bot @@ -153,6 +154,15 @@ async def set_note( await note.create() + await modlog.log_action( + bot=self.bot, + action_type="clear note", + guild=interaction.guild, + member=user, + moderator=interaction.user, + data=f"Note: {body}", + ) + role = discord.utils.get( interaction.guild.roles, name=configuration.get_config_entry( @@ -222,6 +232,15 @@ async def clear_notes( for note in notes: await note.delete() + await modlog.log_action( + bot=self.bot, + action_type="note", + guild=interaction.guild, + member=user, + moderator=interaction.user, + data=f"Notes cleared: {len(notes)}", + ) + role = discord.utils.get( interaction.guild.roles, name=configuration.get_config_entry( diff --git a/modules/moderation/purge.py b/modules/moderation/purge.py index 910060ab..9ac78e19 100644 --- a/modules/moderation/purge.py +++ b/modules/moderation/purge.py @@ -10,6 +10,7 @@ import configuration from core import auxiliary, cogs, moderation +from modules.moderation import modlog if TYPE_CHECKING: import bot @@ -76,14 +77,13 @@ async def purge_command( await interaction.response.send_message("Purge Successful", ephemeral=True) sent_message = await interaction.original_response() deleted = await interaction.channel.purge(after=timestamp, limit=amount) + await modlog.log_action( + bot=self.bot, + action_type="purge", + guild=interaction.guild, + moderator=interaction.user, + ) await interaction.followup.edit_message( message_id=sent_message.id, content=f"Purge Successful. Deleted {len(deleted)} messages.", ) - - await moderation.send_command_usage_alert( - bot_object=self.bot, - interaction=interaction, - command=f"/purge amount: {amount}, duration: {duration_minutes}", - guild=interaction.guild, - ) diff --git a/modules/moderation/report.py b/modules/moderation/report.py index a5d273c0..4ebaa983 100644 --- a/modules/moderation/report.py +++ b/modules/moderation/report.py @@ -11,6 +11,7 @@ import configuration from core import auxiliary, cogs +from modules.moderation import modlog if TYPE_CHECKING: import bot @@ -141,6 +142,13 @@ async def report_command( embed=embed, allowed_mentions=discord.AllowedMentions(roles=True), ) + for index, user in enumerate(mentioned_users): + await modlog.log_action( + bot=self.bot, + action_type="reported", + guild=interaction.guild, + member=user, + ) user_embed = auxiliary.prepare_confirm_embed( message="Your report was successfully sent" diff --git a/modules/operation/application.py b/modules/operation/application.py index 697bf83d..07725d2d 100644 --- a/modules/operation/application.py +++ b/modules/operation/application.py @@ -13,6 +13,7 @@ import configuration import ui from core import auxiliary, cogs +from modules.moderation import modlog if TYPE_CHECKING: import bot @@ -176,6 +177,15 @@ async def ban_user( applicant_id=str(member.id), ) await ban.create() + + await modlog.log_action( + bot=self.bot, + action_type="application ban", + guild=interaction.guild, + member=member, + moderator=interaction.user, + ) + embed = auxiliary.prepare_confirm_embed( f"{member.name} successfully banned from making applications" ) @@ -205,6 +215,15 @@ async def unban_user( bans = await self.get_ban_entry(member) for ban in bans: await ban.delete() + + await modlog.log_action( + bot=self.bot, + action_type="application unban", + guild=interaction.guild, + member=member, + moderator=interaction.user, + ) + embed = auxiliary.prepare_confirm_embed( f"{member.name} successfully unbanned from making applications" ) From 5cf90890656934c17ee7dc9c41b2ef809e1a9181 Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Sat, 13 Jun 2026 17:44:22 -0700 Subject: [PATCH 2/6] Redo modlog commands, other minor tweaks --- modules/moderation/automod.py | 8 +- modules/moderation/moderator.py | 2 +- modules/moderation/modlog.py | 167 +++++++++++++++++++------------- modules/moderation/notes.py | 4 +- modules/moderation/report.py | 1 + 5 files changed, 107 insertions(+), 75 deletions(-) diff --git a/modules/moderation/automod.py b/modules/moderation/automod.py index 9b16c128..06009d9e 100644 --- a/modules/moderation/automod.py +++ b/modules/moderation/automod.py @@ -175,7 +175,7 @@ async def response( member=ctx.author, reason=total_punishment.violation_string, expires_at=expires_at, - data=f"Violating message: {ctx.message.clean_content[:100]}", + data=f"**Violating message:** {ctx.message.clean_content[:200]}", ) logged = True @@ -202,7 +202,7 @@ async def response( guild=ctx.guild, member=ctx.author, reason=total_punishment.violation_string, - data=f"Violating message: {ctx.message.clean_content[:100]}", + data=f"**Violating message:** {ctx.message.clean_content[:200]}", ) logged = True max_warnings = configuration.get_config_entry( @@ -246,7 +246,7 @@ async def response( guild=ctx.guild, member=ctx.author, reason=total_punishment.violation_string, - data=f"Violating message: {ctx.message.clean_content[:100]}", + data=f"**Violating message:** {ctx.message.clean_content[:100]}", ) if not logged: @@ -256,7 +256,7 @@ async def response( guild=ctx.guild, member=ctx.author, reason=total_punishment.violation_string, - data=f"Violating message: {ctx.message.clean_content[:100]}", + data=f"**Violating message:** {ctx.message.clean_content[:200]}", ) if total_punishment.be_silent: diff --git a/modules/moderation/moderator.py b/modules/moderation/moderator.py index 39311635..92c588c6 100644 --- a/modules/moderation/moderator.py +++ b/modules/moderation/moderator.py @@ -719,7 +719,7 @@ async def handle_warning_clear( member=target, moderator=interaction.user, reason=reason, - data=f"Total warnings: {len(warnings)}", + data=f"**Total warnings:** {len(warnings)}", ) embed = generate_response_embed( diff --git a/modules/moderation/modlog.py b/modules/moderation/modlog.py index 045ccc7f..4d60c8c4 100644 --- a/modules/moderation/modlog.py +++ b/modules/moderation/modlog.py @@ -38,32 +38,37 @@ class ModLogger(cogs.BaseCog): """ modlog_group: app_commands.Group = app_commands.Group( - name="modlog", description="..." + name="modlog", + description="Commands that query the database related to mod logs", ) @modlog_group.command( name="highscores", - description="Shows the top 10 moderators based on ban count", + description="Shows the top 10 moderators based on mod action count", ) async def high_score_command(self: Self, interaction: discord.Interaction) -> None: - """Gets the top 10 moderators based on banned user count + """Gets the top 10 moderators based on mod action count Args: interaction (discord.Interaction): The interaction that started this command """ await interaction.response.defer() - all_bans = await self.bot.models.BanLog.query.where( - self.bot.models.BanLog.guild_id == str(interaction.guild.id) + all_actions = await self.bot.models.ModLog.query.where( + self.bot.models.ModLog.guild_id == str(interaction.guild.id) ).gino.all() - ban_frequency_counter = Counter(ban.banning_moderator for ban in all_bans) + frequency_counter = Counter( + action.moderator_id + for action in all_actions + if action.moderator_id is not None + ) - sorted_ban_frequency = sorted( - ban_frequency_counter.items(), key=lambda x: x[1], reverse=True + sorted_frequency = sorted( + frequency_counter.items(), key=lambda x: x[1], reverse=True ) embed = discord.Embed(title="Most active moderators") final_string = "" - for index, (moderator_id, count) in enumerate(sorted_ban_frequency[:10]): + for index, (moderator_id, count) in enumerate(sorted_frequency[:10]): try: moderator = await interaction.guild.fetch_member(int(moderator_id)) except discord.NotFound: @@ -84,7 +89,8 @@ async def high_score_command(self: Self, interaction: discord.Interaction) -> No @modlog_group.command( name="lookup-user", - description="Looks up the 10 most recent bans for a given user", + description="Looks up mod actions taken against a given user", + extras={"ephemeral_error": True}, ) async def lookup_user_command( self: Self, interaction: discord.Interaction, user: discord.User @@ -95,39 +101,51 @@ async def lookup_user_command( interaction (discord.Interaction): The interaction that called the command user (discord.User): The user to search for bans for """ + await interaction.response.defer(ephemeral=True) - await interaction.response.defer(ephemeral=False) - - all_bans_by_user = ( - await self.bot.models.BanLog.query.where( - self.bot.models.BanLog.guild_id == str(interaction.guild.id) + all_action_for_user = ( + await self.bot.models.ModLog.query.where( + self.bot.models.ModLog.guild_id == str(interaction.guild.id) ) - .where(self.bot.models.BanLog.banned_member == str(user.id)) - .order_by(self.bot.models.BanLog.ban_time.desc()) + .where(self.bot.models.ModLog.member_id == str(user.id)) + .order_by(self.bot.models.ModLog.action_time.desc()) .gino.all() ) embeds = [] - for ban in all_bans_by_user[:10]: - temp_embed = await self.convert_ban_to_pretty_string( - ban, f"{user.name} bans" + embed = discord.Embed(title=f"Actions for {user.name}") + embed.color = discord.Color.red() + for index, action in enumerate(all_action_for_user): + if index % 10 == 0 and index > 0: + embeds.append(embed) + embed = discord.Embed(title=f"Actions for {user.name}") + embed.color = discord.Color.red() + embed.add_field( + name=f"Case {action.guild_case_id} | {action.action.title()}", + value=( + f"Reason: {action.reason if action.reason else "No reason specified"}\n" + f"" + ), + inline=False, ) - temp_embed.description += f"\n**Total bans:** {len(all_bans_by_user)}" - embeds.append(temp_embed) + embeds.append(embed) if len(embeds) == 0: embed = auxiliary.prepare_deny_embed( - f"No bans for the user {user.name} could be found" + f"No actions for the user {user.name} could be found" ) - await interaction.followup.send(embed=embed) + await interaction.followup.send(embed=embed, ephemeral=True) return view = ui.PaginateView() - await view.send(interaction.channel, interaction.user, embeds, interaction) + await view.send( + interaction.channel, interaction.user, embeds, interaction, ephemeral=True + ) @modlog_group.command( name="lookup-moderator", - description="Looks up the 10 most recent bans by a given moderator", + description="Looks up the mod actions taken by a given moderator", + extras={"ephemeral_error": True}, ) async def lookup_moderator_command( self: Self, interaction: discord.Interaction, moderator: discord.Member @@ -138,59 +156,72 @@ async def lookup_moderator_command( interaction (discord.Interaction): The interaction that called the command moderator (discord.Member): The moderator to search for bans for """ - await interaction.response.defer(ephemeral=False) + await interaction.response.defer(ephemeral=True) - all_bans_by_user = ( - await self.bot.models.BanLog.query.where( - self.bot.models.BanLog.guild_id == str(interaction.guild.id) + all_action_for_user = ( + await self.bot.models.ModLog.query.where( + self.bot.models.ModLog.guild_id == str(interaction.guild.id) ) - .where(self.bot.models.BanLog.banning_moderator == str(moderator.id)) - .order_by(self.bot.models.BanLog.ban_time.desc()) + .where(self.bot.models.ModLog.moderator_id == str(moderator.id)) + .order_by(self.bot.models.ModLog.action_time.desc()) .gino.all() ) embeds = [] - for ban in all_bans_by_user[:10]: - temp_embed = await self.convert_ban_to_pretty_string( - ban, f"Bans by {moderator.name}" + embed = discord.Embed(title=f"Actions by {moderator.name}") + embed.color = discord.Color.red() + for index, action in enumerate(all_action_for_user): + if index % 10 == 0 and index > 0: + embeds.append(embed) + embed = discord.Embed(title=f"Actions by {moderator.name}") + embed.color = discord.Color.red() + embed.add_field( + name=f"Case {action.guild_case_id} | {action.action.title()}", + value=( + f"Member ID: {action.member_id}\n" + f"Reason: {action.reason if action.reason else "No reason specified"}\n" + f"" + ), + inline=False, ) - temp_embed.description += f"\n**Total bans:** {len(all_bans_by_user)}" - embeds.append(temp_embed) + embeds.append(embed) if len(embeds) == 0: embed = auxiliary.prepare_deny_embed( - f"No bans by the user {moderator.name} could be found" + f"No actions for the user {moderator.name} could be found" ) - await interaction.followup.send(embed=embed) + await interaction.followup.send(embed=embed, ephemeral=True) return view = ui.PaginateView() - await view.send(interaction.channel, interaction.user, embeds, interaction) - - async def convert_ban_to_pretty_string( - self: Self, ban: munch.Munch, title: str - ) -> discord.Embed: - """This converts a database ban entry into a shiny embed + await view.send( + interaction.channel, interaction.user, embeds, interaction, ephemeral=True + ) - Args: - ban (munch.Munch): The ban database entry - title (str): The title to set the embeds to + @modlog_group.command( + name="lookup", + description="Looks up a case by the given id", + extras={"ephemeral_error": True}, + ) + async def lookup_case_command( + self: Self, interaction: discord.Interaction, case_number: int + ) -> None: + await interaction.response.defer(ephemeral=True) - Returns: - discord.Embed: The fancy embed - """ - member = await self.bot.fetch_user(int(ban.banned_member)) - moderator = await self.bot.fetch_user(int(ban.banning_moderator)) - embed = discord.Embed(title=title) - embed.description = ( - f"**Case:** {ban.pk}\n" - f"**Offender:** {member.name} {member.mention}\n" - f"**Reason:** {ban.reason}\n" - f"**Responsible moderator:** {moderator.name} {moderator.mention}" + case = ( + await self.bot.models.ModLog.query.where( + self.bot.models.ModLog.guild_id == str(interaction.guild.id) + ) + .where(self.bot.models.ModLog.guild_case_id == case_number) + .gino.first() ) - embed.timestamp = ban.ban_time - embed.color = discord.Color.red() - return embed + if not case: + embed = auxiliary.prepare_deny_embed( + f"The case number {case_number} could not be found" + ) + else: + embed = await generate_action_embed(self.bot, case) + await interaction.followup.send(embed=embed, ephemeral=True) @commands.Cog.listener() async def on_audit_log_entry_create( @@ -356,7 +387,7 @@ async def on_automod_action(self: Self, execution: discord.AutoModAction) -> Non member=member, reason=rule.name, expires_at=expires_at, - data=f"Violating content: {execution.content[:200]}", + data=f"**Violating content:** {execution.content[:200]}", ) elif any( action.type == discord.AutoModRuleActionType.block_member_interactions @@ -368,11 +399,11 @@ async def on_automod_action(self: Self, execution: discord.AutoModAction) -> Non ): await log_action( bot=self.bot, - action_type="Quarantine", + action_type="quarantine", guild=rule.guild, member=member, reason=rule.name, - data=f"Violating name: {member.display_name}", + data=f"**Violating name:** {member.display_name}", ) elif any( action.type == discord.AutoModRuleActionType.block_message @@ -385,7 +416,7 @@ async def on_automod_action(self: Self, execution: discord.AutoModAction) -> Non guild=rule.guild, member=member, reason=rule.name, - data=f"Violating name: {member.display_name}", + data=f"**Violating name:** {member.display_name}", ) else: await log_action( @@ -394,7 +425,7 @@ async def on_automod_action(self: Self, execution: discord.AutoModAction) -> Non guild=rule.guild, member=member, reason=rule.name, - data=f"Violating content: {execution.content[:200]}", + data=f"**Violating content:** {execution.content[:200]}", ) @commands.Cog.listener() @@ -496,7 +527,7 @@ async def generate_action_embed( # If this action has extra data, display it as is. if action_entry.data: # This might need special handling for specific events, we will see - description_strs.append(f"**Data:** {action_entry.data}") + description_strs.append(action_entry.data) # If this action has an expiration date, display it if action_entry.until_time: diff --git a/modules/moderation/notes.py b/modules/moderation/notes.py index 1ac68a71..21770e1f 100644 --- a/modules/moderation/notes.py +++ b/modules/moderation/notes.py @@ -160,7 +160,7 @@ async def set_note( guild=interaction.guild, member=user, moderator=interaction.user, - data=f"Note: {body}", + data=f"**Note:** {body}", ) role = discord.utils.get( @@ -238,7 +238,7 @@ async def clear_notes( guild=interaction.guild, member=user, moderator=interaction.user, - data=f"Notes cleared: {len(notes)}", + data=f"**Total notes:** {len(notes)}", ) role = discord.utils.get( diff --git a/modules/moderation/report.py b/modules/moderation/report.py index 4ebaa983..3a3b28f1 100644 --- a/modules/moderation/report.py +++ b/modules/moderation/report.py @@ -147,6 +147,7 @@ async def report_command( bot=self.bot, action_type="reported", guild=interaction.guild, + reason=reason, member=user, ) From 4c4bacb06feb73903bc8c019bfcee6d54fd686fd Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Sat, 13 Jun 2026 17:50:54 -0700 Subject: [PATCH 3/6] Formatting changes --- changelog.md | 6 ++++++ core/databases.py | 10 +++++++--- core/moderation.py | 2 -- modules/moderation/modlog.py | 10 ++++++++-- modules/moderation/purge.py | 2 +- 5 files changed, 22 insertions(+), 8 deletions(-) diff --git a/changelog.md b/changelog.md index 1eb876c4..2e4c1304 100644 --- a/changelog.md +++ b/changelog.md @@ -70,6 +70,12 @@ Changes since 2026.06.04 ### Moderator - Adds autocomplete for /unwarn command - Bans from max warnings will now show a ban in the text output +- Removes command use logging, in favor of modlog + +### Modlog +- Completely rewrites modlog to log all moderation actions +- Reworks the output of the all of the modlog commands +- Adds new command to lookup action by ID ### Modmail - Full migration to application commands diff --git a/core/databases.py b/core/databases.py index ce844f31..2f04bcb5 100644 --- a/core/databases.py +++ b/core/databases.py @@ -71,10 +71,14 @@ class ModLog(bot.db.Model): __tablename__ (str): The name of the table in postgres pk (int): The automatic primary key guild_id (str): The string of the guild ID the user was banned in + guild_case_id (int): The case number of the action taken + action (str): The string representation of the action taken reason (str): The reason of the ban - banning_moderator (str): The ID of the moderator who banned - banned_member (str): The ID of the user who was banned - ban_time (datetime): The date and time of the ban + data (str): Any extra data specific to the event logged + moderator_id (str): The ID of the moderator who banned + member_id (str): The ID of the user who was banned + action_time (datetime): The date and time of the ban + until_time (datetime): The time this action will expire """ __tablename__ = "modlog" diff --git a/core/moderation.py b/core/moderation.py index 0c10e907..d1ec4ebe 100644 --- a/core/moderation.py +++ b/core/moderation.py @@ -6,8 +6,6 @@ import discord import munch -import configuration - async def ban_user( guild: discord.Guild, user: discord.User, delete_seconds: int, reason: str diff --git a/modules/moderation/modlog.py b/modules/moderation/modlog.py index 4d60c8c4..26a32932 100644 --- a/modules/moderation/modlog.py +++ b/modules/moderation/modlog.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio import datetime from collections import Counter from typing import TYPE_CHECKING, Self @@ -206,6 +205,12 @@ async def lookup_moderator_command( async def lookup_case_command( self: Self, interaction: discord.Interaction, case_number: int ) -> None: + """A command to lookup a logged case by ID + + Args: + interaction (discord.Interaction): The interaction that called this command + case_number (int): The case number to lookup from the database + """ await interaction.response.defer(ephemeral=True) case = ( @@ -519,7 +524,8 @@ async def generate_action_embed( if action_entry.moderator_id: moderator_account = await bot.fetch_user(int(action_entry.moderator_id)) description_strs.append( - f"**Responsible moderator:** {moderator_account.name} {moderator_account.mention} ({moderator_account.id})" + f"**Responsible moderator:** {moderator_account.name} " + f"{moderator_account.mention} ({moderator_account.id})" ) else: description_strs.append("**Responsible moderator:** No associated moderator") diff --git a/modules/moderation/purge.py b/modules/moderation/purge.py index 9ac78e19..936babec 100644 --- a/modules/moderation/purge.py +++ b/modules/moderation/purge.py @@ -9,7 +9,7 @@ from discord import app_commands import configuration -from core import auxiliary, cogs, moderation +from core import auxiliary, cogs from modules.moderation import modlog if TYPE_CHECKING: From 6cee7412926e228777d19b276922bde83320c6f7 Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Sat, 13 Jun 2026 17:59:11 -0700 Subject: [PATCH 4/6] Formatting changes --- modules/moderation/report.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/moderation/report.py b/modules/moderation/report.py index 3a3b28f1..763aae75 100644 --- a/modules/moderation/report.py +++ b/modules/moderation/report.py @@ -142,7 +142,7 @@ async def report_command( embed=embed, allowed_mentions=discord.AllowedMentions(roles=True), ) - for index, user in enumerate(mentioned_users): + for user in mentioned_users: await modlog.log_action( bot=self.bot, action_type="reported", From f04fb24410badabf3af3842f30719ad19d200c44 Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Sat, 13 Jun 2026 18:01:12 -0700 Subject: [PATCH 5/6] Remove voice state update from events --- modules/moderation/events.py | 59 ------------------------------------ 1 file changed, 59 deletions(-) diff --git a/modules/moderation/events.py b/modules/moderation/events.py index 53f79b8c..da8b5922 100644 --- a/modules/moderation/events.py +++ b/modules/moderation/events.py @@ -1074,65 +1074,6 @@ async def on_raw_member_remove( embed_message=embed, ) - @commands.Cog.listener() - async def on_voice_state_update( - self: Self, - member: discord.Member, - before: discord.VoiceState, - after: discord.VoiceState, - ) -> None: - """This logs events related to server (un)mute/deafing of members - https://discordpy.readthedocs.io/en/latest/api.html#discord.on_voice_state_update - - Args: - member (discord.Member): The member who had their account changed - before (discord.VoiceState): The previous state of the members voice account - after (discord.VoiceState): The new state of the members voice account - """ - # We need to handle server deafen and server mute - if before.mute != after.mute: - embed = EventEmbed( - title=f"Member Server {'un' if before.mute else ''}muted", - description="", - ) - - embed.setEventAuthor(member) - embed.addMemberField("Member", member) - - if after.channel: - embed.addChannelField("Current Channel", after.channel) - - console_message = f"{embed.title}: {member.name} ({member.id})" - - await self.send_event_log( - guild=member.guild, - log_location="member", - string_message=console_message, - embed_message=embed, - channel_location=after.channel, - ) - - if before.deaf != after.deaf: - embed = EventEmbed( - title=f"Member Server {'un' if before.deaf else ''}deafened", - description="", - ) - - embed.setEventAuthor(member) - embed.addMemberField("Member", member) - - if after.channel: - embed.addChannelField("Current Channel", after.channel) - - console_message = f"{embed.title}: {member.name} ({member.id})" - - await self.send_event_log( - guild=member.guild, - log_location="member", - string_message=console_message, - embed_message=embed, - ) - @commands.Cog.listener() async def on_user_update( self: Self, before: discord.User, after: discord.User From 41c4394a97da7cde0cbeebb061928849a60540ca Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Sun, 14 Jun 2026 07:08:46 -0700 Subject: [PATCH 6/6] Add honeypot to the modlog, fix bug in logging quarantine --- modules/moderation/honeypot.py | 38 +++++++++------------------------- modules/moderation/modlog.py | 19 +++++++++-------- 2 files changed, 20 insertions(+), 37 deletions(-) diff --git a/modules/moderation/honeypot.py b/modules/moderation/honeypot.py index 01cd2bd9..48873233 100644 --- a/modules/moderation/honeypot.py +++ b/modules/moderation/honeypot.py @@ -2,14 +2,13 @@ from __future__ import annotations -import datetime from typing import TYPE_CHECKING, Self -import discord from discord.ext import commands import configuration from core import cogs +from modules.moderation import modlog if TYPE_CHECKING: import bot @@ -61,35 +60,18 @@ async def response( # This should be replaced with a guild wide purge when discord.py can be updated. await ctx.author.ban(delete_message_days=1, reason="triggered honeypot") await ctx.guild.unban(ctx.author, reason="triggered honeypot") - # Send an alert in the alert channel, if its configured - try: - alert_channel = ctx.guild.get_channel( - configuration.get_config_entry(ctx.guild.id, "moderation_alert_channel") - ) - except TypeError: - alert_channel = None - - if not alert_channel: - return - - embed = discord.Embed(title="Honeypot triggered") - embed.add_field( - name="Offending member", - value=f"{ctx.author.global_name} ({ctx.author.name})", - ) - embed.add_field(name="Message text", value=ctx.message.clean_content[:500]) - embed.add_field( - name="Number of attachments", value=len(ctx.message.attachments) - ) - embed.color = discord.Color.red() - embed.timestamp = datetime.datetime.utcnow() - embed.set_footer( - text=f"Author ID: {ctx.author.id} • Message ID: {ctx.message.id}" + await modlog.log_action( + bot=self.bot, + action_type="Honeypot", + guild=ctx.guild, + member=ctx.author, + data=( + f"**Content:** {ctx.message.clean_content[:500]}\n" + f"**Attachments:** {len(ctx.message.attachments)}" + ), ) - await alert_channel.send(embed=embed) - # Get only message in the channel and edit the description # Just in case, we make sure we pick the first message in the channel, as a foolproof method history = ctx.channel.history(oldest_first=True, limit=1) diff --git a/modules/moderation/modlog.py b/modules/moderation/modlog.py index 26a32932..21915ec9 100644 --- a/modules/moderation/modlog.py +++ b/modules/moderation/modlog.py @@ -376,7 +376,7 @@ async def on_automod_action(self: Self, execution: discord.AutoModAction) -> Non """ # I hate everything about this rule = await execution.fetch_rule() - member = await self.bot.fetch_user(execution.user_id) + member = await rule.guild.fetch_member(execution.user_id) if any( action.type == discord.AutoModRuleActionType.timeout for action in rule.actions @@ -402,14 +402,15 @@ async def on_automod_action(self: Self, execution: discord.AutoModAction) -> Non execution.action.type == discord.AutoModRuleActionType.block_member_interactions ): - await log_action( - bot=self.bot, - action_type="quarantine", - guild=rule.guild, - member=member, - reason=rule.name, - data=f"**Violating name:** {member.display_name}", - ) + if member.flags.automod_quarantined_username: + await log_action( + bot=self.bot, + action_type="quarantine", + guild=rule.guild, + member=member, + reason=rule.name, + data=f"**Violating name:** {member.display_name}", + ) elif any( action.type == discord.AutoModRuleActionType.block_message for action in rule.actions