diff --git a/changelog.md b/changelog.md index 8a4a6d05..37a741b9 100644 --- a/changelog.md +++ b/changelog.md @@ -73,6 +73,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 76c26faf..98a16a90 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 @@ -71,20 +71,35 @@ class BanLog(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__ = "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 @@ -370,7 +385,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..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 @@ -203,67 +201,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..06009d9e 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[:200]}", + ) + 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[:200]}", + ) + 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[:200]}", + ) + if total_punishment.be_silent: return 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 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/moderator.py b/modules/moderation/moderator.py index f0e90df2..92c588c6 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..21915ec9 100644 --- a/modules/moderation/modlog.py +++ b/modules/moderation/modlog.py @@ -6,6 +6,7 @@ from collections import Counter from typing import TYPE_CHECKING, Self +import asyncpg import discord import munch from discord import app_commands @@ -25,10 +26,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: @@ -36,32 +37,37 @@ class BanLogger(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: @@ -82,7 +88,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 @@ -93,39 +100,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 @@ -136,219 +155,489 @@ 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) + await view.send( + interaction.channel, interaction.user, embeds, interaction, ephemeral=True + ) - 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 + @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: + """A command to lookup a logged case by ID Args: - ban (munch.Munch): The ban database entry - title (str): The title to set the embeds to - - Returns: - discord.Embed: The fancy embed + interaction (discord.Interaction): The interaction that called this command + case_number (int): The case number to lookup from the database """ - 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}" + await interaction.response.defer(ephemeral=True) + + 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_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 rule.guild.fetch_member(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 + ): + 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 + ): + 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} " + f"{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(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 -async def log_unban( + return latest_case.guild_case_id + 1 + + +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 +649,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..21770e1f 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"**Total notes:** {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..936babec 100644 --- a/modules/moderation/purge.py +++ b/modules/moderation/purge.py @@ -9,7 +9,8 @@ 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: 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..763aae75 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,14 @@ async def report_command( embed=embed, allowed_mentions=discord.AllowedMentions(roles=True), ) + for user in mentioned_users: + await modlog.log_action( + bot=self.bot, + action_type="reported", + guild=interaction.guild, + reason=reason, + 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" )