From 996ef0bd21ddf244fd7d166b36bb3c4b05c600d7 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 6 Jun 2025 16:24:40 +0000 Subject: [PATCH] Implement new moderation features --- cogs/aimod.py | 193 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 190 insertions(+), 3 deletions(-) diff --git a/cogs/aimod.py b/cogs/aimod.py index f0bd7ef..7ef9e54 100644 --- a/cogs/aimod.py +++ b/cogs/aimod.py @@ -14,6 +14,7 @@ from PIL import Image # For image processing import cv2 # For video processing import numpy as np # For array operations import tempfile # For temporary file operations +import shutil # For backing up files from typing import Optional, List, Dict, Any, Tuple # For type hinting import asyncio import aiofiles @@ -58,6 +59,9 @@ MOD_LOG_API_SECRET_ENV_VAR = "MOD_LOG_API_SECRET" GUILD_CONFIG_DIR = "data/" # Using the existing directory for all json data GUILD_CONFIG_PATH = os.path.join(GUILD_CONFIG_DIR, "guild_config.json") USER_INFRACTIONS_PATH = os.path.join(GUILD_CONFIG_DIR, "user_infractions.json") +INFRACTION_BACKUP_DIR = os.path.join(GUILD_CONFIG_DIR, "infraction_backups") + +os.makedirs(INFRACTION_BACKUP_DIR, exist_ok=True) os.makedirs(GUILD_CONFIG_DIR, exist_ok=True) @@ -261,8 +265,54 @@ class AIModerationCog(commands.Cog): ".mkv", ".flv", ] # Expanded list + self.backup_task = self.bot.loop.create_task( + self.backup_infractions_periodically() + ) print("AIModerationCog Initialized.") + class QuickActionView(discord.ui.View): + """Buttons for quick moderator actions.""" + + def __init__(self, parent: "AIModerationCog", target: discord.Member): + super().__init__(timeout=3600) + self.parent = parent + self.target = target + + @discord.ui.button(label="Escalate Ban", style=discord.ButtonStyle.danger) + async def escalate( + self, interaction: discord.Interaction, button: discord.ui.Button + ): + if not interaction.user.guild_permissions.ban_members: + await interaction.response.send_message( + "You lack permission to ban members.", ephemeral=True + ) + return + try: + await self.target.ban(reason="Escalated via mod panel") + await interaction.response.send_message( + f"Banned {self.target.mention}.", ephemeral=True + ) + except Exception as e: # noqa: BLE001 + await interaction.response.send_message( + f"Failed to ban: {e}", ephemeral=True + ) + self.disable_all_items() + await interaction.message.edit(view=self) + + @discord.ui.button(label="Ignore", style=discord.ButtonStyle.secondary) + async def ignore( + self, interaction: discord.Interaction, button: discord.ui.Button + ): + if interaction.user.guild_permissions.manage_messages: + await interaction.message.delete() + await interaction.response.send_message( + "Notification dismissed.", ephemeral=True + ) + else: + await interaction.response.send_message( + "No permission to manage messages.", ephemeral=True + ) + async def cog_load(self): """Called when the cog is loaded.""" print("AIModerationCog cog_load started.") @@ -287,6 +337,22 @@ class AIModerationCog(commands.Cog): # The genai.Client doesn't have an explicit close method in the same way aiohttp.ClientSession does. # It typically manages its own resources. print("AIModerationCog Unloaded.") + if self.backup_task: + self.backup_task.cancel() + + async def backup_infractions_periodically(self): + """Periodically back up the infractions file.""" + await self.bot.wait_until_ready() + while not self.bot.is_closed(): + timestamp = datetime.datetime.utcnow().strftime("%Y%m%d_%H%M%S") + backup_path = os.path.join( + INFRACTION_BACKUP_DIR, f"user_infractions_{timestamp}.json" + ) + try: + shutil.copy(USER_INFRACTIONS_PATH, backup_path) + except Exception as e: # noqa: BLE001 + print(f"Failed to back up infractions: {e}") + await asyncio.sleep(24 * 60 * 60) async def process_image(self, attachment: discord.Attachment) -> tuple[str, bytes]: """ @@ -453,6 +519,34 @@ class AIModerationCog(commands.Cog): parent=aimod_group, ) + @aimod_group.command( + name="sync", + description="Reload AI moderation configuration and infractions from disk.", + ) + @app_commands.checks.has_permissions(administrator=True) + async def aimod_sync(self, interaction: discord.Interaction): + """Reload configuration files from disk.""" + try: + async with aiofiles.open(GUILD_CONFIG_PATH, "r", encoding="utf-8") as f: + data = await f.read() + async with CONFIG_LOCK: + global GUILD_CONFIG + GUILD_CONFIG = json.loads(data) + async with aiofiles.open( + USER_INFRACTIONS_PATH, "r", encoding="utf-8" + ) as f2: + data2 = await f2.read() + async with CONFIG_LOCK: + global USER_INFRACTIONS + USER_INFRACTIONS = json.loads(data2) + await interaction.response.send_message( + "Configuration synced from disk.", ephemeral=True + ) + except Exception as e: # noqa: BLE001 + await interaction.response.send_message( + f"Failed to reload configuration: {e}", ephemeral=True + ) + @config_subgroup.command( name="log_channel", description="Set the moderation log channel." ) @@ -602,6 +696,23 @@ class AIModerationCog(commands.Cog): ephemeral=False, ) + @config_subgroup.command( + name="event_mode", + description="Toggle temporary event mode for this guild.", + ) + @app_commands.describe(enabled="Enable event mode (true/false)") + async def event_mode(self, interaction: discord.Interaction, enabled: bool): + if not interaction.user.guild_permissions.administrator: + await interaction.response.send_message( + "You must be an administrator to use this command.", ephemeral=False + ) + return + await set_guild_config(interaction.guild.id, "EVENT_MODE", enabled) + await interaction.response.send_message( + f"Event mode is now {'enabled' if enabled else 'disabled'}.", + ephemeral=False, + ) + @infractions_subgroup.command( name="view", description="View a user's AI moderation infraction history (mod/admin only).", @@ -701,6 +812,61 @@ class AIModerationCog(commands.Cog): ephemeral=False, ) + @infractions_subgroup.command( + name="leaderboard", + description="Show users with the fewest infractions.", + ) + async def leaderboard(self, interaction: discord.Interaction): + guild_id = interaction.guild.id + counts = {} + for key, infractions in USER_INFRACTIONS.items(): + if key.startswith(f"{guild_id}_"): + uid = int(key.split("_", 1)[1]) + counts[uid] = len(infractions) + if not counts: + await interaction.response.send_message( + "No infractions recorded for this guild.", ephemeral=True + ) + return + sorted_users = sorted(counts.items(), key=lambda x: x[1])[:5] + lines = [] + for uid, count in sorted_users: + member = interaction.guild.get_member(uid) + name = member.display_name if member else f"ID:{uid}" + lines.append(f"**{name}** - {count} infractions") + embed = discord.Embed( + title="Best Behavior Leaderboard", + description="\n".join(lines), + color=discord.Color.green(), + ) + await interaction.response.send_message(embed=embed, ephemeral=False) + + @infractions_subgroup.command( + name="restore", + description="Restore infractions from the latest backup (admin only).", + ) + @app_commands.checks.has_permissions(administrator=True) + async def restore_infractions(self, interaction: discord.Interaction): + backups = sorted(os.listdir(INFRACTION_BACKUP_DIR)) + if not backups: + await interaction.response.send_message("No backups found.", ephemeral=True) + return + latest = os.path.join(INFRACTION_BACKUP_DIR, backups[-1]) + try: + shutil.copy(latest, USER_INFRACTIONS_PATH) + async with aiofiles.open(USER_INFRACTIONS_PATH, "r", encoding="utf-8") as f: + data = await f.read() + async with CONFIG_LOCK: + global USER_INFRACTIONS + USER_INFRACTIONS = json.loads(data) + await interaction.response.send_message( + f"Infractions restored from {backups[-1]}", ephemeral=False + ) + except Exception as e: # noqa: BLE001 + await interaction.response.send_message( + f"Failed to restore infractions: {e}", ephemeral=True + ) + @model_subgroup.command( name="set", description="Change the AI model used for moderation (admin only)." ) @@ -1789,10 +1955,26 @@ CRITICAL: Do NOT output anything other than the required JSON response. if not suicidal_role: print(f"ERROR: Suicidal ping role ID {suicidal_role_id} not found.") final_message = f"{ping_target}\n{action_taken_message}" - await log_channel.send(content=final_message, embed=notification_embed) + await log_channel.send( + content=final_message, + embed=notification_embed, + view=self.QuickActionView(self, message.author), + ) elif moderator_role: # For other violations - final_message = f"{mod_ping}\n{action_taken_message}" - await log_channel.send(content=final_message, embed=notification_embed) + suggestions_id = get_guild_config( + message.guild.id, "SUGGESTIONS_CHANNEL_ID" + ) + suggestion_note = ( + f"\nPlease review <#{suggestions_id}> for rule updates." + if suggestions_id + else "" + ) + final_message = f"{mod_ping}\n{action_taken_message}{suggestion_note}" + await log_channel.send( + content=final_message, + embed=notification_embed, + view=self.QuickActionView(self, message.author), + ) else: # Fallback if moderator role is also not found for non-suicidal actions print( f"ERROR: Moderator role ID {moderator_role_id} not found for action {action}." @@ -1858,6 +2040,11 @@ CRITICAL: Do NOT output anything other than the required JSON response. f"Moderation disabled for guild {message.guild.id}. Ignoring message {message.id}." ) return + if get_guild_config(message.guild.id, "EVENT_MODE", False): + print( + f"Event mode enabled for guild {message.guild.id}. Ignoring message {message.id}." + ) + return # --- Suicidal Content Check --- # Suicidal keyword check removed; handled by OpenRouter AI moderation.