From d845418443b923b9b017a5016d23e3a9f69c8dc3 Mon Sep 17 00:00:00 2001 From: Slipstream Date: Sat, 7 Jun 2025 04:01:03 +0000 Subject: [PATCH] Applying previous commit. --- cogs/aimod_cog.py | 231 +++++++++++++++++++++++++++++++++++++++++-- cogs/aimod_config.py | 53 ++++++++++ 2 files changed, 278 insertions(+), 6 deletions(-) diff --git a/cogs/aimod_cog.py b/cogs/aimod_cog.py index 02339e0..5a129e2 100644 --- a/cogs/aimod_cog.py +++ b/cogs/aimod_cog.py @@ -39,12 +39,18 @@ from .aimod_config import ( GUILD_CONFIG_PATH, USER_INFRACTIONS_PATH, INFRACTION_BACKUP_DIR, + USER_APPEALS_PATH, + APPEAL_AI_MODEL, + APPEAL_AI_THINKING_BUDGET, CONFIG_LOCK, save_user_infractions, + save_user_appeals, get_guild_config, set_guild_config, get_user_infraction_history, add_user_infraction, + get_user_appeals, + add_user_appeal, SERVER_RULES, SUICIDAL_HELP_RESOURCES, ) @@ -508,6 +514,9 @@ class AIModerationCog(commands.Cog): infractions_subgroup = app_commands.Group( name="infractions", description="Manage user infractions.", parent=aimod_group ) + appeal_subgroup = app_commands.Group( + name="appeal", description="Appeal AI moderation actions.", parent=aimod_group + ) model_subgroup = app_commands.Group( name="model", description="Manage the AI model for moderation.", @@ -844,6 +853,59 @@ class AIModerationCog(commands.Cog): await interaction.response.send_message(embed=embed, ephemeral=False) + @appeal_subgroup.command( + name="human_review", + description="Request a human moderator to review your case.", + ) + @app_commands.describe( + reason="Explain why you want a human to review the AI decision", + guild_id="If using in DMs, provide the server ID", + ) + async def appeal_human_review( + self, + interaction: discord.Interaction, + reason: str, + guild_id: int | None = None, + ): + """Let a user request a manual moderator review.""" + guild = interaction.guild or ( + self.bot.get_guild(guild_id) if guild_id else None + ) + if not guild: + await interaction.response.send_message( + "Invalid or missing guild ID.", ephemeral=True + ) + return + + log_channel_id = get_guild_config(guild.id, "MOD_LOG_CHANNEL_ID") + log_channel = self.bot.get_channel(log_channel_id) if log_channel_id else None + if not log_channel: + await interaction.response.send_message( + "Appeals are not enabled for this server.", ephemeral=True + ) + return + + timestamp = datetime.datetime.utcnow().isoformat() + await add_user_appeal( + guild.id, interaction.user.id, "HUMAN_REVIEW", reason, timestamp, "" + ) + + embed = discord.Embed( + title="Human Review Requested", color=discord.Color.orange() + ) + embed.add_field( + name="User", + value=f"{interaction.user} ({interaction.user.id})", + inline=False, + ) + embed.add_field(name="Request", value=reason, inline=False) + embed.timestamp = discord.utils.utcnow() + await log_channel.send(embed=embed) + + await interaction.response.send_message( + "Your request for a human review has been sent.", ephemeral=True + ) + @infractions_subgroup.command( name="clear", description="Clear a user's AI moderation infraction history (admin only).", @@ -933,6 +995,102 @@ class AIModerationCog(commands.Cog): f"Failed to restore infractions: {e}", ephemeral=True ) + @appeal_subgroup.command(name="submit", description="Submit a moderation appeal.") + @app_commands.describe( + action="The action you are appealing", + reason="Explain why you believe the action was incorrect", + guild_id="If using in DMs, provide the server ID", + ) + async def appeal_submit( + self, + interaction: discord.Interaction, + action: str, + reason: str, + guild_id: int | None = None, + ): + guild = interaction.guild or ( + self.bot.get_guild(guild_id) if guild_id else None + ) + if not guild: + await interaction.response.send_message( + "Invalid or missing guild ID.", ephemeral=True + ) + return + + log_channel_id = get_guild_config(guild.id, "MOD_LOG_CHANNEL_ID") + log_channel = self.bot.get_channel(log_channel_id) if log_channel_id else None + if not log_channel: + await interaction.response.send_message( + "Appeals are not enabled for this server.", ephemeral=True + ) + return + + ai_review = await self.run_appeal_ai(guild, interaction.user, action, reason) + timestamp = datetime.datetime.utcnow().isoformat() + await add_user_appeal( + guild.id, interaction.user.id, action, reason, timestamp, ai_review + ) + + embed = discord.Embed(title="New Appeal", color=discord.Color.blue()) + embed.add_field( + name="User", + value=f"{interaction.user} ({interaction.user.id})", + inline=False, + ) + embed.add_field(name="Action", value=action, inline=False) + embed.add_field(name="Appeal", value=reason, inline=False) + embed.add_field(name="AI Review", value=ai_review[:1000], inline=False) + embed.timestamp = discord.utils.utcnow() + await log_channel.send(embed=embed) + + await interaction.response.send_message( + "Your appeal has been submitted.", ephemeral=True + ) + + @appeal_subgroup.command( + name="list", description="View a user's appeals (mods only)." + ) + @app_commands.describe(user="The user to view appeals for") + async def appeal_list(self, interaction: discord.Interaction, user: discord.Member): + moderator_role_id = get_guild_config(interaction.guild.id, "MODERATOR_ROLE_ID") + moderator_role = ( + interaction.guild.get_role(moderator_role_id) if moderator_role_id else None + ) + has_permission = interaction.user.guild_permissions.administrator or ( + moderator_role and moderator_role in interaction.user.roles + ) + if not has_permission: + await interaction.response.send_message( + "You must be an administrator or have the moderator role to use this command.", + ephemeral=True, + ) + return + + appeals = get_user_appeals(interaction.guild.id, user.id) + if not appeals: + await interaction.response.send_message( + f"{user.mention} has no appeals.", ephemeral=False + ) + return + + embed = discord.Embed( + title=f"Appeals for {user.display_name}", color=discord.Color.blue() + ) + for i, appeal in enumerate(appeals, 1): + ts = appeal.get("timestamp", "?")[:19].replace("T", " ") + summary = appeal.get("appeal_text", "") + ai_sum = appeal.get("ai_review", "") + if len(summary) > 150: + summary = summary[:147] + "..." + if len(ai_sum) > 150: + ai_sum = ai_sum[:147] + "..." + embed.add_field( + name=f"Appeal #{i} - {ts}", + value=f"Action: {appeal.get('action')}\nReason: {summary}\nAI: {ai_sum}", + inline=False, + ) + await interaction.response.send_message(embed=embed, ephemeral=False) + @model_subgroup.command( name="set", description="Change the AI model used for moderation (admin only)." ) @@ -1480,9 +1638,7 @@ CRITICAL: Do NOT output anything other than the required JSON response. else: model_path = model_id_to_use - thinking_config = types.ThinkingConfig( - thinking_budget=0 - ) + thinking_config = types.ThinkingConfig(thinking_budget=0) generation_config = types.GenerateContentConfig( temperature=0.2, @@ -1985,10 +2141,24 @@ CRITICAL: Do NOT output anything other than the required JSON response. ) try: dm_channel = await message.author.create_dm() - await dm_channel.send( - f"Your recent message in **{message.guild.name}** was removed for violating Rule **{rule_violated}**. " - f"Reason: _{reasoning}_. Please review the server rules. This is a formal warning." + warn_embed = discord.Embed( + title="⚠️ Moderation Warning", + description=( + f"Your recent message in **{message.guild.name}** was removed for violating **Rule {rule_violated}**." + ), + color=discord.Color.orange(), ) + if message.content: + warn_embed.add_field( + name="Message Content", + value=message.content[:1024], + inline=False, + ) + warn_embed.add_field(name="Reason", value=reasoning, inline=False) + warn_embed.set_footer( + text="Please review the server rules. This is a formal warning." + ) + await dm_channel.send(embed=warn_embed) action_taken_message += " User notified via DM with warning." except discord.Forbidden: print( @@ -2164,6 +2334,55 @@ CRITICAL: Do NOT output anything other than the required JSON response. "FATAL: Bot lacks permission to send messages, even error notifications." ) + async def run_appeal_ai( + self, guild: discord.Guild, member: discord.User, action: str, appeal_text: str + ) -> str: + """Run the appeal text through the higher tier AI model.""" + if not self.genai_client: + return "AI review unavailable." + + history = get_user_infraction_history(guild.id, member.id) + history_text = json.dumps(history, indent=2) if history else "None" + + system_prompt = ( + "You are reviewing a user's appeal of a moderation action. " + "Think very extensively about the appeal, the provided history, and the server rules. " + "Return a short verdict (UPHOLD or OVERTURN) and your reasoning in plain text." + ) + + user_prompt = ( + f"Server Rules:\n{SERVER_RULES}\n\n" + f"User History:\n{history_text}\n\n" + f"Action Appealed: {action}\n" + f"Appeal Text: {appeal_text}" + ) + + generation_config = types.GenerateContentConfig( + temperature=0.2, + max_output_tokens=8192, + safety_settings=STANDARD_SAFETY_SETTINGS, + thinking_config=types.ThinkingConfig( + thinking_budget=APPEAL_AI_THINKING_BUDGET + ), + system_instruction=types.Content( + role="system", parts=[types.Part(text=system_prompt)] + ), + ) + + try: + response = await self.genai_client.aio.models.generate_content( + model=f"publishers/google/models/{APPEAL_AI_MODEL}", + contents=[ + types.Content(role="user", parts=[types.Part(text=user_prompt)]) + ], + config=generation_config, + ) + result = self._get_response_text(response) + return result or "AI review failed to produce output." + except Exception as e: # noqa: BLE001 + print(f"Appeal AI error: {e}") + return "AI review encountered an error." + @commands.Cog.listener(name="on_message") async def message_listener(self, message: discord.Message): """Listens to messages and triggers moderation checks.""" diff --git a/cogs/aimod_config.py b/cogs/aimod_config.py index cd4b2c7..95e2913 100644 --- a/cogs/aimod_config.py +++ b/cogs/aimod_config.py @@ -31,6 +31,12 @@ GUILD_CONFIG_DIR = "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") +USER_APPEALS_PATH = os.path.join(GUILD_CONFIG_DIR, "user_appeals.json") + +# AI model used for appeal reviews +APPEAL_AI_MODEL = "gemini-2.5-pro-preview-06-05" +# Thinking budget for appeal AI +APPEAL_AI_THINKING_BUDGET = 32768 os.makedirs(INFRACTION_BACKUP_DIR, exist_ok=True) os.makedirs(GUILD_CONFIG_DIR, exist_ok=True) @@ -55,6 +61,16 @@ except Exception as e: # noqa: BLE001 print(f"Failed to load user infractions from {USER_INFRACTIONS_PATH}: {e}") USER_INFRACTIONS = {} +if not os.path.exists(USER_APPEALS_PATH): + with open(USER_APPEALS_PATH, "w", encoding="utf-8") as f: + json.dump({}, f) +try: + with open(USER_APPEALS_PATH, "r", encoding="utf-8") as f: + USER_APPEALS = json.load(f) +except Exception as e: # noqa: BLE001 + print(f"Failed to load user appeals from {USER_APPEALS_PATH}: {e}") + USER_APPEALS = {} + CONFIG_LOCK = asyncio.Lock() @@ -76,6 +92,15 @@ async def save_user_infractions(): print(f"Failed to save user infractions: {e}") +async def save_user_appeals(): + async with CONFIG_LOCK: + try: + async with aiofiles.open(USER_APPEALS_PATH, "w", encoding="utf-8") as f: + await f.write(json.dumps(USER_APPEALS, indent=2)) + except Exception as e: # noqa: BLE001 + print(f"Failed to save user appeals: {e}") + + def get_guild_config(guild_id: int, key: str, default=None): guild_str = str(guild_id) if guild_str in GUILD_CONFIG and key in GUILD_CONFIG[guild_str]: @@ -119,6 +144,34 @@ async def add_user_infraction( await save_user_infractions() +def get_user_appeals(guild_id: int, user_id: int) -> list: + key = f"{guild_id}_{user_id}" + return USER_APPEALS.get(key, []) + + +async def add_user_appeal( + guild_id: int, + user_id: int, + action: str, + appeal_text: str, + timestamp: str, + ai_review: str, +): + key = f"{guild_id}_{user_id}" + if key not in USER_APPEALS: + USER_APPEALS[key] = [] + + appeal_record = { + "timestamp": timestamp, + "action": action, + "appeal_text": appeal_text, + "ai_review": ai_review, + } + USER_APPEALS[key].append(appeal_record) + USER_APPEALS[key] = USER_APPEALS[key][-10:] + await save_user_appeals() + + DEFAULT_SERVER_RULES = """ # Server Rules