import discord from discord.ext import commands from discord import app_commands, Interaction, Embed, Color, User, Member, Object, ui import asyncpg import logging from typing import Optional, Union, Dict, Any import datetime # Use absolute imports from the discordbot package root from db import mod_log_db import settings_manager as sm # Use module functions directly log = logging.getLogger(__name__) class ModLogCog(commands.Cog): """Cog for handling integrated moderation logging and related commands.""" def __init__(self, bot: commands.Bot): self.bot = bot # Settings manager functions are used directly from the imported module self.pool: asyncpg.Pool = bot.pg_pool # Assuming pool is attached to bot # Create the main command group for this cog self.modlog_group = app_commands.Group( name="modlog", description="Commands for viewing and managing moderation logs", ) # Register commands within the group self.register_commands() # Add command group to the bot's tree self.bot.tree.add_command(self.modlog_group) class LogView(ui.LayoutView): """View used for moderation log messages.""" def __init__( self, bot: commands.Bot, title: str, color: discord.Color, lines: list[str], footer: str, ): super().__init__(timeout=None) container = ui.Container(accent_colour=color) self.add_item(container) container.add_item(ui.TextDisplay(f"**{title}**")) container.add_item(ui.Separator(spacing=discord.SeparatorSpacing.small)) for line in lines: container.add_item(ui.TextDisplay(line)) container.add_item(ui.Separator(spacing=discord.SeparatorSpacing.small)) self.footer_display = ui.TextDisplay(footer) container.add_item(self.footer_display) def _format_user( self, user: Union[Member, User, Object], guild: Optional[discord.Guild] = None ) -> str: """Return a string with display name, username and ID for a user-like object.""" if isinstance(user, Object): return f"Unknown User (ID: {user.id})" if isinstance(user, Member): display = user.display_name elif guild and isinstance(user, User): member = guild.get_member(user.id) display = member.display_name if member else user.name else: display = user.name username = ( f"{user.name}#{user.discriminator}" if isinstance(user, (Member, User)) else "Unknown" ) return f"{display} ({username}) [ID: {user.id}]" async def _fetch_user_display(self, user_id: int, guild: discord.Guild) -> str: """Fetch and format a user by ID for display.""" member = guild.get_member(user_id) if member: return self._format_user(member, guild) user = self.bot.get_user(user_id) if user: return self._format_user(user, guild) try: user = await self.bot.fetch_user(user_id) return self._format_user(user, guild) except discord.HTTPException: return f"Unknown User (ID: {user_id})" def register_commands(self): """Register all commands for this cog""" # --- Set Channel Command --- setchannel_command = app_commands.Command( name="setchannel", description="Set the channel for moderation logs and enable logging.", callback=self.modlog_setchannel_callback, parent=self.modlog_group, ) app_commands.describe(channel="The text channel to send moderation logs to.")( setchannel_command ) self.modlog_group.add_command(setchannel_command) # --- View Command --- view_command = app_commands.Command( name="view", description="View moderation logs for a user or the server", callback=self.modlog_view_callback, parent=self.modlog_group, ) app_commands.describe(user="Optional: The user whose logs you want to view")( view_command ) self.modlog_group.add_command(view_command) # --- Case Command --- case_command = app_commands.Command( name="case", description="View details for a specific moderation case ID", callback=self.modlog_case_callback, parent=self.modlog_group, ) app_commands.describe(case_id="The ID of the moderation case to view")( case_command ) self.modlog_group.add_command(case_command) # --- Reason Command --- reason_command = app_commands.Command( name="reason", description="Update the reason for a specific moderation case ID", callback=self.modlog_reason_callback, parent=self.modlog_group, ) app_commands.describe( case_id="The ID of the moderation case to update", new_reason="The new reason for the moderation action", )(reason_command) self.modlog_group.add_command(reason_command) # --- Command Callbacks --- @app_commands.checks.has_permissions(manage_guild=True) async def modlog_setchannel_callback( self, interaction: Interaction, channel: discord.TextChannel ): """Callback for the /modlog setchannel command.""" await interaction.response.defer(ephemeral=True) guild_id = interaction.guild_id if not guild_id: await interaction.followup.send( "❌ This command can only be used in a server.", ephemeral=True ) return if not channel or not isinstance(channel, discord.TextChannel): await interaction.followup.send( "❌ Invalid channel provided. Please specify a valid text channel.", ephemeral=True, ) return # Check if the bot has permissions to send messages in the target channel bot_member = interaction.guild.me if not channel.permissions_for(bot_member).send_messages: await interaction.followup.send( f"❌ I don't have permission to send messages in {channel.mention}. Please grant me 'Send Messages' permission there.", ephemeral=True, ) return if not channel.permissions_for(bot_member).embed_links: await interaction.followup.send( f"❌ I don't have permission to send embeds in {channel.mention}. Please grant me 'Embed Links' permission there.", ephemeral=True, ) return try: # Set the mod log channel ID set_channel_success = await sm.set_mod_log_channel_id(guild_id, channel.id) # Enable mod logging set_enabled_success = await sm.set_mod_log_enabled(guild_id, True) if set_channel_success and set_enabled_success: await interaction.followup.send( f"✅ Moderation logs will now be sent to {channel.mention} and logging is enabled.", ephemeral=True, ) log.info( f"Mod log channel set to {channel.id} and logging enabled for guild {guild_id} by {interaction.user.id}" ) else: await interaction.followup.send( "❌ Failed to save moderation log settings. Please check the bot logs for more details.", ephemeral=True, ) log.error( f"Failed to set mod log channel/enabled status for guild {guild_id}. Channel success: {set_channel_success}, Enabled success: {set_enabled_success}" ) except Exception as e: log.exception(f"Error setting mod log channel for guild {guild_id}: {e}") await interaction.followup.send( "❌ An unexpected error occurred while setting the moderation log channel. Please try again later.", ephemeral=True, ) # --- Core Logging Function --- async def log_action( self, guild: discord.Guild, moderator: Union[User, Member], # For bot actions target: Union[ User, Member, Object ], # Can be user, member, or just an ID object action_type: str, reason: Optional[str], duration: Optional[datetime.timedelta] = None, source: str = "BOT", # Default source is the bot itself ai_details: Optional[Dict[str, Any]] = None, # Details from AI API moderator_id_override: Optional[ int ] = None, # Allow overriding moderator ID for AI source ): """Logs a moderation action to the database and configured channel.""" if not guild: log.warning("Attempted to log action without guild context.") return guild_id = guild.id # Use override if provided (for AI source), otherwise use moderator object ID moderator_id = ( moderator_id_override if moderator_id_override is not None else moderator.id ) target_user_id = target.id duration_seconds = int(duration.total_seconds()) if duration else None # 1. Add initial log entry to DB case_id = await mod_log_db.add_mod_log( self.pool, guild_id, moderator_id, target_user_id, action_type, reason, duration_seconds, ) if not case_id: log.error( f"Failed to get case_id when logging action {action_type} in guild {guild_id}" ) return # Don't proceed if we couldn't save the initial log # 2. Check settings and send log message try: # Use functions from settings_manager module log_enabled = await sm.is_mod_log_enabled(guild_id, default=False) log_channel_id = await sm.get_mod_log_channel_id(guild_id) if not log_enabled or not log_channel_id: log.debug( f"Mod logging disabled or channel not set for guild {guild_id}. Skipping Discord log message." ) return log_channel = guild.get_channel(log_channel_id) if not log_channel or not isinstance(log_channel, discord.TextChannel): log.warning( f"Mod log channel {log_channel_id} not found or not a text channel in guild {guild_id}." ) # Optionally update DB to remove channel ID? Or just leave it. return # 3. Format and send view view = self._format_log_embed( case_id=case_id, moderator=moderator, # Pass the object for display formatting target=target, action_type=action_type, reason=reason, duration=duration, guild=guild, source=source, ai_details=ai_details, moderator_id_override=moderator_id_override, # Pass override for formatting ) log_message = await log_channel.send(view=view) # 4. Update DB with message details await mod_log_db.update_mod_log_message_details( self.pool, case_id, log_message.id, log_channel.id ) except Exception as e: log.exception( f"Error during Discord mod log message sending/updating for case {case_id} in guild {guild_id}: {e}" ) def _format_log_embed( self, case_id: int, moderator: Union[User, Member], target: Union[User, Member, Object], action_type: str, reason: Optional[str], duration: Optional[datetime.timedelta], guild: discord.Guild, source: str = "BOT", ai_details: Optional[Dict[str, Any]] = None, moderator_id_override: Optional[int] = None, ) -> ui.LayoutView: """Helper function to create the standard log view.""" color_map = { "BAN": Color.red(), "UNBAN": Color.green(), "KICK": Color.orange(), "TIMEOUT": Color.gold(), "REMOVE_TIMEOUT": Color.blue(), "WARN": Color.yellow(), "AI_ALERT": Color.purple(), "AI_DELETE_REQUESTED": Color.dark_grey(), } embed_color = ( Color.blurple() if source == "AI_API" else color_map.get(action_type.upper(), Color.greyple()) ) action_title_prefix = ( "🤖 AI Moderation Action" if source == "AI_API" else action_type.replace("_", " ").title() ) action_title = f"{action_title_prefix} | Case #{case_id}" target_display = self._format_user(target, guild) moderator_display = ( f"AI System (ID: {moderator_id_override or 'Unknown'})" if source == "AI_API" else self._format_user(moderator, guild) ) lines = [f"**User:** {target_display}", f"**Moderator:** {moderator_display}"] if ai_details: if "rule_violated" in ai_details: lines.append(f"**Rule Violated:** {ai_details['rule_violated']}") if "reasoning" in ai_details: reason_to_display = reason or ai_details["reasoning"] lines.append( f"**Reason / AI Reasoning:** {reason_to_display or 'No reason provided.'}" ) if reason and reason != ai_details["reasoning"]: lines.append(f"**Original Bot Reason:** {reason}") else: lines.append(f"**Reason:** {reason or 'No reason provided.'}") if "message_content" in ai_details: message_content = ai_details["message_content"] if len(message_content) > 1000: message_content = message_content[:997] + "..." lines.append(f"**Message Content:** {message_content}") else: lines.append(f"**Reason:** {reason or 'No reason provided.'}") if duration: total_seconds = int(duration.total_seconds()) days, remainder = divmod(total_seconds, 86400) hours, remainder = divmod(remainder, 3600) minutes, seconds = divmod(remainder, 60) duration_str = "" if days > 0: duration_str += f"{days}d " if hours > 0: duration_str += f"{hours}h " if minutes > 0: duration_str += f"{minutes}m " if seconds > 0 or not duration_str: duration_str += f"{seconds}s" duration_str = duration_str.strip() lines.append(f"**Duration:** {duration_str}") if action_type.upper() == "TIMEOUT": expires_at = discord.utils.utcnow() + duration lines.append(f"**Expires:** ") footer = ( f"AI Moderation Action • {guild.name} ({guild.id})" + ( f" • Model: {ai_details.get('ai_model')}" if ai_details and ai_details.get("ai_model") else "" ) if source == "AI_API" else f"Guild: {guild.name} ({guild.id})" ) return self.LogView(self.bot, action_title, embed_color, lines, footer) # --- View Command Callback --- @app_commands.checks.has_permissions( moderate_members=True ) # Adjust permissions as needed async def modlog_view_callback( self, interaction: Interaction, user: Optional[discord.User] = None ): """Callback for the /modlog view command.""" await interaction.response.defer(ephemeral=True) guild_id = interaction.guild_id if not guild_id: await interaction.followup.send( "❌ This command can only be used in a server.", ephemeral=True ) return records = [] if user: records = await mod_log_db.get_user_mod_logs(self.pool, guild_id, user.id) title = f"Moderation Logs for {user.name} ({user.id})" else: records = await mod_log_db.get_guild_mod_logs(self.pool, guild_id) title = f"Recent Moderation Logs for {interaction.guild.name}" if not records: await interaction.followup.send( "No moderation logs found matching your criteria.", ephemeral=True ) return # Format the logs into an embed or text response # For simplicity, sending as text for now. Can enhance with pagination/embeds later. response_lines = [f"**{title}**"] for record in records: timestamp_str = record["timestamp"].strftime("%Y-%m-%d %H:%M:%S") reason_str = record["reason"] or "N/A" duration_str = ( f" ({record['duration_seconds']}s)" if record["duration_seconds"] else "" ) target_disp = await self._fetch_user_display( record["target_user_id"], interaction.guild ) if record["moderator_id"] == 0: mod_disp = "AI System" else: mod_disp = await self._fetch_user_display( record["moderator_id"], interaction.guild ) response_lines.append( f"`Case #{record['case_id']}` [{timestamp_str}] **{record['action_type']}** " f"Target: {target_disp} Mod: {mod_disp} " f"Reason: {reason_str}{duration_str}" ) # Handle potential message length limits full_response = "\n".join(response_lines) if len(full_response) > 2000: full_response = full_response[:1990] + "\n... (truncated)" await interaction.followup.send(full_response, ephemeral=True) @app_commands.checks.has_permissions( moderate_members=True ) # Adjust permissions as needed async def modlog_case_callback(self, interaction: Interaction, case_id: int): """Callback for the /modlog case command.""" await interaction.response.defer(ephemeral=True) record = await mod_log_db.get_mod_log(self.pool, case_id) if not record: await interaction.followup.send( f"❌ Case ID #{case_id} not found.", ephemeral=True ) return # Ensure the case belongs to the current guild for security/privacy if record["guild_id"] != interaction.guild_id: await interaction.followup.send( f"❌ Case ID #{case_id} does not belong to this server.", ephemeral=True ) return # Fetch user objects if possible to show names # Special handling for AI moderator (ID 0) to avoid Discord API 404 error if record["moderator_id"] == 0: # AI moderator uses ID 0, which is not a valid Discord user ID moderator = None else: try: moderator = await self.bot.fetch_user(record["moderator_id"]) except discord.NotFound: log.warning( f"Moderator with ID {record['moderator_id']} not found when viewing case {case_id}" ) moderator = None try: target = await self.bot.fetch_user(record["target_user_id"]) except discord.NotFound: log.warning( f"Target user with ID {record['target_user_id']} not found when viewing case {case_id}" ) target = None duration = ( datetime.timedelta(seconds=record["duration_seconds"]) if record["duration_seconds"] else None ) view = self._format_log_embed( case_id, moderator or Object( id=record["moderator_id"] ), # Fallback to Object if user not found target or Object( id=record["target_user_id"] ), # Fallback to Object if user not found record["action_type"], record["reason"], duration, interaction.guild, ) # Add log message link if available if record["log_message_id"] and record["log_channel_id"]: link = f"https://discord.com/channels/{record['guild_id']}/{record['log_channel_id']}/{record['log_message_id']}" # Append jump link as extra line view.footer_display.content += f" | [Jump to Log]({link})" await interaction.followup.send(view=view, ephemeral=True) @app_commands.checks.has_permissions( manage_guild=True ) # Higher permission for editing reasons async def modlog_reason_callback( self, interaction: Interaction, case_id: int, new_reason: str ): """Callback for the /modlog reason command.""" await interaction.response.defer(ephemeral=True) # 1. Get the original record to verify guild and existence original_record = await mod_log_db.get_mod_log(self.pool, case_id) if not original_record: await interaction.followup.send( f"❌ Case ID #{case_id} not found.", ephemeral=True ) return if original_record["guild_id"] != interaction.guild_id: await interaction.followup.send( f"❌ Case ID #{case_id} does not belong to this server.", ephemeral=True ) return # 2. Update the reason in the database success = await mod_log_db.update_mod_log_reason(self.pool, case_id, new_reason) if not success: await interaction.followup.send( f"❌ Failed to update reason for Case ID #{case_id}. Please check logs.", ephemeral=True, ) return await interaction.followup.send( f"✅ Updated reason for Case ID #{case_id}.", ephemeral=True ) # 3. (Optional but recommended) Update the original log message embed if original_record["log_message_id"] and original_record["log_channel_id"]: try: log_channel = interaction.guild.get_channel( original_record["log_channel_id"] ) if log_channel and isinstance(log_channel, discord.TextChannel): log_message = await log_channel.fetch_message( original_record["log_message_id"] ) if log_message and log_message.author == self.bot.user: # Re-fetch users/duration to reconstruct embed accurately # Special handling for AI moderator (ID 0) to avoid Discord API 404 error if original_record["moderator_id"] == 0: # AI moderator uses ID 0, which is not a valid Discord user ID moderator = None else: try: moderator = await self.bot.fetch_user( original_record["moderator_id"] ) except discord.NotFound: log.warning( f"Moderator with ID {original_record['moderator_id']} not found when updating case {case_id}" ) moderator = None try: target = await self.bot.fetch_user( original_record["target_user_id"] ) except discord.NotFound: log.warning( f"Target user with ID {original_record['target_user_id']} not found when updating case {case_id}" ) target = None duration = ( datetime.timedelta( seconds=original_record["duration_seconds"] ) if original_record["duration_seconds"] else None ) new_view = self._format_log_embed( case_id, moderator or Object(id=original_record["moderator_id"]), target or Object(id=original_record["target_user_id"]), original_record["action_type"], new_reason, # Use the new reason here duration, interaction.guild, ) link = f"https://discord.com/channels/{original_record['guild_id']}/{original_record['log_channel_id']}/{original_record['log_message_id']}" new_view.footer_display.content += f" | [Jump to Log]({link}) | Updated By: {interaction.user.mention}" await log_message.edit(view=new_view) log.info( f"Successfully updated log message view for case {case_id}" ) except discord.NotFound: log.warning( f"Original log message or channel not found for case {case_id} when updating reason." ) except discord.Forbidden: log.warning( f"Missing permissions to edit original log message for case {case_id}." ) except Exception as e: log.exception( f"Error updating original log message embed for case {case_id}: {e}" ) @commands.Cog.listener() async def on_ready(self): # Ensure the pool and settings_manager are available if not hasattr(self.bot, "pg_pool") or not self.bot.pg_pool: log.error( "Database pool not found on bot object. ModLogCog requires bot.pg_pool." ) # Consider preventing the cog from loading fully or raising an error # Settings manager is imported directly, no need to check on bot object print(f"{self.__class__.__name__} cog has been loaded.") async def setup(bot: commands.Bot): # Ensure dependencies (pool) are ready before adding cog # Settings manager is imported directly within the cog if hasattr(bot, "pg_pool") and bot.pg_pool: await bot.add_cog(ModLogCog(bot)) else: log.error("Failed to load ModLogCog: bot.pg_pool not initialized.")