import discord from discord.ext import commands, tasks from discord import AllowedMentions, ui import datetime import difflib import asyncio import aiohttp # Added for webhook sending import logging # Use logging instead of print from typing import Optional, Union # Import settings manager try: from .. import settings_manager # Relative import if cogs are in a subfolder except ImportError: import settings_manager # Fallback for direct execution? Adjust as needed. log = logging.getLogger(__name__) # Setup logger for this cog # Mapping for consistent event styling EVENT_STYLES = { "message_edit": ("โœ๏ธ", discord.Color.light_grey()), "message_delete": ("๐Ÿ—‘๏ธ", discord.Color.dark_grey()), } # Define all possible event keys for toggling # Keep this list updated if new loggable events are added ALL_EVENT_KEYS = sorted( [ # Direct Events "member_join", "member_remove", "member_ban_event", "member_unban", "member_update", "role_create_event", "role_delete_event", "role_update_event", "channel_create_event", "channel_delete_event", "channel_update_event", "message_edit", "message_delete", "reaction_add", "reaction_remove", "reaction_clear", "reaction_clear_emoji", "voice_state_update", "guild_update_event", "emoji_update_event", "invite_create_event", "invite_delete_event", "command_error", # Potentially noisy "thread_create", "thread_delete", "thread_update", "thread_member_join", "thread_member_remove", "webhook_update", # Audit Log Actions (prefixed with 'audit_') "audit_kick", "audit_prune", "audit_ban", "audit_unban", "audit_member_role_update", "audit_member_update_timeout", # Specific member_update cases "audit_message_delete", "audit_message_bulk_delete", "audit_role_create", "audit_role_delete", "audit_role_update", "audit_channel_create", "audit_channel_delete", "audit_channel_update", "audit_emoji_create", "audit_emoji_delete", "audit_emoji_update", "audit_invite_create", "audit_invite_delete", "audit_guild_update", # Add more audit keys if needed, e.g., "audit_stage_instance_create" ] ) class LoggingCog(commands.Cog): """Handles comprehensive server event logging via webhooks with granular toggling.""" def __init__(self, bot: commands.Bot): self.bot = bot self.session: Optional[aiohttp.ClientSession] = None # Session for webhooks self.last_audit_log_ids: dict[int, Optional[int]] = ( {} ) # Store last ID per guild # Start the audit log poller task if the bot is ready, otherwise wait if bot.is_ready(): asyncio.create_task(self.initialize_cog()) # Use async init helper else: asyncio.create_task( self.start_audit_log_poller_when_ready() ) # Keep this for initial start class LogView(ui.LayoutView): """View used for logging messages.""" def __init__( self, bot: commands.Bot, title: str, description: str, color: discord.Color, author: Optional[discord.abc.User], footer: Optional[str], ) -> None: super().__init__(timeout=None) self.container = ui.Container(accent_colour=color) self.add_item(self.container) title_display = ui.TextDisplay(f"**{title}**") desc_display = ui.TextDisplay(description) if description else None self.header_items: list[ui.TextDisplay] = [title_display] if desc_display: self.header_items.append(desc_display) self.header_section: Optional[ui.Section] = None if author is not None: self.header_section = ui.Section( accessory=ui.Thumbnail(media=author.display_avatar.url) ) for item in self.header_items: self.header_section.add_item(item) self.container.add_item(self.header_section) else: for item in self.header_items: self.container.add_item(item) self.container.add_item( ui.Separator(spacing=discord.SeparatorSpacing.small) ) # Use same container to avoid nesting issues and track separator self.content_container = self.container self.bottom_separator = ui.Separator(spacing=discord.SeparatorSpacing.small) self.container.add_item(self.bottom_separator) timestamp = discord.utils.format_dt(datetime.datetime.utcnow(), style="f") parts = [timestamp, footer or f"Bot ID: {bot.user.id}"] if author: parts.append(f"User ID: {author.id}") footer_text = " | ".join(parts) self.footer_display = ui.TextDisplay(footer_text) self.container.add_item(self.footer_display) def add_field(self, name: str, value: str, inline: bool = False) -> None: field = ui.TextDisplay(f"**{name}:** {value}") # Ensure the field is properly registered with the view by using # add_item first, then repositioning it before the bottom separator if hasattr(self.container, "_children"): self.container.add_item(field) try: children = self.container._children index = children.index(self.bottom_separator) children.remove(field) children.insert(index, field) except ValueError: # Fallback to default behaviour if the separator is missing pass else: self.content_container.add_item(field) def set_author(self, user: discord.abc.User) -> None: """Add or update the thumbnail and append the user ID to the footer.""" if self.header_section is None: self.header_section = ui.Section( accessory=ui.Thumbnail(media=user.display_avatar.url) ) for item in self.header_items: self.container.remove_item(item) self.header_section.add_item(item) # Insert at the beginning to keep layout consistent if hasattr(self.container, "children"): self.container.children.insert(0, self.header_section) else: self.container.add_item(self.header_section) else: self.header_section.accessory = ui.Thumbnail( media=user.display_avatar.url ) if "User ID:" not in self.footer_display.content: self.footer_display.content += f" | User ID: {user.id}" def set_footer(self, text: str) -> None: """Replace the footer text while preserving the timestamp.""" timestamp = discord.utils.format_dt(datetime.datetime.utcnow(), style="f") self.footer_display.content = f"{timestamp} | {text}" def _user_display(self, user: Union[discord.Member, discord.User]) -> str: """Return display name, username and ID string for a user.""" display = user.display_name if isinstance(user, discord.Member) else user.name username = f"{user.name}#{user.discriminator}" return f"{display} ({username}) [ID: {user.id}]" async def initialize_cog(self): """Asynchronous initialization tasks.""" log.info("Initializing LoggingCog...") self.session = aiohttp.ClientSession() log.info("aiohttp ClientSession created for LoggingCog.") await self.initialize_audit_log_ids() if not self.poll_audit_log.is_running(): self.poll_audit_log.start() log.info("Audit log poller started during initialization.") async def initialize_audit_log_ids(self): """Fetch the latest audit log ID for each guild the bot is in.""" log.info("Initializing last audit log IDs for guilds...") for guild in self.bot.guilds: if ( guild.id not in self.last_audit_log_ids ): # Only initialize if not already set try: if guild.me.guild_permissions.view_audit_log: async for entry in guild.audit_logs(limit=1): self.last_audit_log_ids[guild.id] = entry.id log.debug( f"Initialized last_audit_log_id for guild {guild.id} to {entry.id}" ) break # Only need the latest one else: log.warning( f"Missing 'View Audit Log' permission in guild {guild.id}. Cannot initialize audit log ID." ) self.last_audit_log_ids[guild.id] = ( None # Mark as unable to fetch ) except discord.Forbidden: log.warning( f"Forbidden error fetching initial audit log ID for guild {guild.id}." ) self.last_audit_log_ids[guild.id] = None except discord.HTTPException as e: log.error( f"HTTP error fetching initial audit log ID for guild {guild.id}: {e}" ) self.last_audit_log_ids[guild.id] = None except Exception as e: log.exception( f"Unexpected error fetching initial audit log ID for guild {guild.id}: {e}" ) self.last_audit_log_ids[guild.id] = ( None # Mark as unable on other errors ) log.info("Finished initializing audit log IDs.") async def start_audit_log_poller_when_ready(self): """Waits until bot is ready, then initializes and starts the poller.""" await self.bot.wait_until_ready() await self.initialize_cog() # Call the main init helper async def cog_unload(self): """Clean up resources when the cog is unloaded.""" self.poll_audit_log.cancel() log.info("Audit log poller stopped.") if self.session and not self.session.closed: await self.session.close() log.info("aiohttp ClientSession closed for LoggingCog.") async def _send_log_embed(self, guild: discord.Guild, embed: ui.LayoutView) -> None: """Sends the log view via the configured webhook for the guild.""" if not self.session or self.session.closed: log.error( f"aiohttp session not available or closed in LoggingCog for guild {guild.id}. Cannot send log." ) return webhook_url = await settings_manager.get_logging_webhook(guild.id) if not webhook_url: # log.debug(f"Logging webhook not configured for guild {guild.id}. Skipping log.") # Can be noisy return try: webhook = discord.Webhook.from_url( webhook_url, session=self.session, client=self.bot, ) await webhook.send( view=embed, username=f"{self.bot.user.name} Logs", avatar_url=self.bot.user.display_avatar.url, allowed_mentions=AllowedMentions.none(), ) # log.debug(f"Sent log embed via webhook for guild {guild.id}") # Can be noisy except ValueError as e: log.exception( f"ValueError sending log via webhook for guild {guild.id}. Error: {e}" ) # Consider notifying an admin or disabling logging for this guild temporarily # await settings_manager.set_logging_webhook(guild.id, None) # Example: Auto-disable on invalid URL except (discord.Forbidden, discord.NotFound): log.error( f"Webhook permissions error or webhook not found for guild {guild.id}. URL: {webhook_url}" ) # Consider notifying an admin or disabling logging for this guild temporarily # await settings_manager.set_logging_webhook(guild.id, None) # Example: Auto-disable on error except discord.HTTPException as e: log.error(f"HTTP error sending log via webhook for guild {guild.id}: {e}") except aiohttp.ClientError as e: log.error( f"aiohttp client error sending log via webhook for guild {guild.id}: {e}" ) except Exception as e: log.exception( f"Unexpected error sending log via webhook for guild {guild.id}: {e}" ) def _create_log_embed( self, title: str, description: str = "", color: discord.Color = discord.Color.blue(), author: Optional[Union[discord.User, discord.Member]] = None, footer: Optional[str] = None, ) -> ui.LayoutView: """Creates a standardized log view.""" return self.LogView(self.bot, title, description, color, author, footer) def _add_id_footer( self, embed: ui.LayoutView, obj: Union[ discord.Member, discord.User, discord.Role, discord.abc.GuildChannel, discord.Message, discord.Invite, None, ] = None, obj_id: Optional[int] = None, id_name: str = "ID", ) -> None: """Adds an ID to the footer text if possible.""" target_id = obj_id or (obj.id if obj else None) if target_id: existing_footer = getattr(embed, "footer_display", None) if existing_footer: parts = [f"{id_name}: {target_id}"] link = None if hasattr(obj, "jump_url"): link = f"[Jump]({obj.jump_url})" elif isinstance(obj, discord.abc.GuildChannel): link = obj.mention if link: parts.append(link) sep = " | " if existing_footer.content else "" existing_footer.content += sep + " | ".join(parts) async def _check_log_enabled(self, guild_id: int, event_key: str) -> bool: """Checks if logging is enabled for a specific event key in a guild.""" # First, check if the webhook is configured at all webhook_url = await settings_manager.get_logging_webhook(guild_id) if not webhook_url: return False # Then, check if the specific event is enabled (defaults to True if not set) enabled = await settings_manager.is_log_event_enabled( guild_id, event_key, default_enabled=True ) # if not enabled: # log.debug(f"Logging disabled for event '{event_key}' in guild {guild_id}") return enabled async def _is_recent_audit_log_for_target( self, guild: discord.Guild, action: discord.AuditLogAction, target_id: int, max_age: float = 5.0, ) -> bool: """Return True if the latest audit log entry matches the target within ``max_age`` seconds.""" try: async for entry in guild.audit_logs(limit=1, action=action): if ( entry.target.id == target_id and (discord.utils.utcnow() - entry.created_at).total_seconds() <= max_age ): return True return False except discord.Forbidden: return True except Exception: return False # --- Log Command Group --- @commands.group(name="log", invoke_without_command=True) @commands.guild_only() @commands.has_permissions(administrator=True) async def log_group(self, ctx: commands.Context): """Manages logging settings. Use subcommands like 'channel', 'toggle', 'status', 'list_keys'.""" await ctx.send_help(ctx.command) @log_group.command(name="channel") @commands.has_permissions(administrator=True) async def log_channel(self, ctx: commands.Context, channel: discord.TextChannel): """Sets the channel for logging and creates/updates the webhook. (Admin Only)""" guild = ctx.guild me = guild.me # 1. Check bot permissions if not channel.permissions_for(me).manage_webhooks: await ctx.send( f"โŒ I don't have the 'Manage Webhooks' permission in {channel.mention}. Please grant it and try again.", allowed_mentions=AllowedMentions.none(), ) return if not channel.permissions_for(me).send_messages: await ctx.send( f"โŒ I don't have the 'Send Messages' permission in {channel.mention}. Please grant it and try again (needed for webhook creation confirmation).", allowed_mentions=AllowedMentions.none(), ) return # 2. Check existing webhook setting existing_url = await settings_manager.get_logging_webhook(guild.id) if existing_url: # Try to fetch the existing webhook to see if it's still valid and in the right channel try: if not self.session or self.session.closed: self.session = aiohttp.ClientSession() # Ensure session exists existing_webhook = await discord.Webhook.from_url( existing_url, session=self.session ).fetch() if existing_webhook.channel_id == channel.id: await ctx.send( f"โœ… Logging is already configured for {channel.mention} using webhook `{existing_webhook.name}`.", allowed_mentions=AllowedMentions.none(), ) return else: await ctx.send( f"โš ๏ธ Logging webhook is currently set for a different channel (<#{existing_webhook.channel_id}>). I will create a new one for {channel.mention}.", allowed_mentions=AllowedMentions.none(), ) except ( discord.NotFound, discord.Forbidden, ValueError, aiohttp.ClientError, ): await ctx.send( f"โš ๏ธ Could not verify the existing webhook URL. It might be invalid or deleted. I will create a new one for {channel.mention}.", allowed_mentions=AllowedMentions.none(), ) except Exception as e: log.exception( f"Error fetching existing webhook during setup for guild {guild.id}" ) await ctx.send( f"โš ๏ธ An error occurred while checking the existing webhook. Proceeding to create a new one for {channel.mention}.", allowed_mentions=AllowedMentions.none(), ) # 3. Create new webhook try: webhook_name = f"{self.bot.user.name} Logger" # Use bot's avatar if possible avatar_bytes = None try: avatar_bytes = await self.bot.user.display_avatar.read() except Exception: log.warning( f"Could not read bot avatar for webhook creation in guild {guild.id}." ) new_webhook = await channel.create_webhook( name=webhook_name, avatar=avatar_bytes, reason=f"Logging setup by {ctx.author} ({ctx.author.id})", ) log.info( f"Created logging webhook '{webhook_name}' in channel {channel.id} for guild {guild.id}" ) except discord.HTTPException as e: log.error( f"Failed to create webhook in {channel.mention} for guild {guild.id}: {e}" ) await ctx.send( f"โŒ Failed to create webhook. Error: {e}. This could be due to hitting the channel webhook limit (15).", allowed_mentions=AllowedMentions.none(), ) return except Exception as e: log.exception( f"Unexpected error creating webhook in {channel.mention} for guild {guild.id}" ) await ctx.send( "โŒ An unexpected error occurred while creating the webhook.", allowed_mentions=AllowedMentions.none(), ) return # 4. Save webhook URL success = await settings_manager.set_logging_webhook(guild.id, new_webhook.url) if success: await ctx.send( f"โœ… Successfully configured logging to send messages to {channel.mention} via the new webhook `{new_webhook.name}`.", allowed_mentions=AllowedMentions.none(), ) # Test send (optional) try: test_view = self._create_log_embed( "โœ… Logging Setup Complete", f"Logs will now be sent to this channel via the webhook `{new_webhook.name}`.", color=discord.Color.green(), ) await new_webhook.send( embed=test_view, username=webhook_name, avatar_url=self.bot.user.display_avatar.url, allowed_mentions=AllowedMentions.none(), ) except Exception as e: log.error( f"Failed to send test message via new webhook for guild {guild.id}: {e}" ) await ctx.send( "โš ๏ธ Could not send a test message via the new webhook, but the URL has been saved.", allowed_mentions=AllowedMentions.none(), ) else: log.error( f"Failed to save webhook URL {new_webhook.url} to database for guild {guild.id}" ) await ctx.send( "โŒ Successfully created the webhook, but failed to save its URL to my settings. Please try again or contact support.", allowed_mentions=AllowedMentions.none(), ) # Attempt to delete the created webhook to avoid orphans try: await new_webhook.delete(reason="Failed to save URL to settings") log.info( f"Deleted orphaned webhook '{new_webhook.name}' for guild {guild.id}" ) except Exception as del_e: log.error( f"Failed to delete orphaned webhook '{new_webhook.name}' for guild {guild.id}: {del_e}" ) @log_group.command(name="toggle") @commands.has_permissions(administrator=True) async def log_toggle( self, ctx: commands.Context, event_key: str, enabled_status: Optional[bool] = None, ): """Toggles logging for a specific event type (on/off). Use 'log list_keys' to see available event keys. If [on|off] is not provided, the current status will be flipped. Example: !log toggle message_edit off Example: !log toggle audit_kick """ guild_id = ctx.guild.id event_key = event_key.lower() # Ensure case-insensitivity if event_key not in ALL_EVENT_KEYS: await ctx.send( f"โŒ Invalid event key: `{event_key}`. Use `{ctx.prefix}log list_keys` to see valid keys.", allowed_mentions=AllowedMentions.none(), ) return # Determine the new status if enabled_status is None: # Fetch current status (defaults to True if not explicitly set) current_status = await settings_manager.is_log_event_enabled( guild_id, event_key, default_enabled=True ) new_status = not current_status else: new_status = enabled_status # Save the new status success = await settings_manager.set_log_event_enabled( guild_id, event_key, new_status ) if success: status_str = "ENABLED" if new_status else "DISABLED" await ctx.send( f"โœ… Logging for event `{event_key}` is now **{status_str}**.", allowed_mentions=AllowedMentions.none(), ) else: await ctx.send( f"โŒ Failed to update setting for event `{event_key}`. Please check logs or try again.", allowed_mentions=AllowedMentions.none(), ) @log_group.command(name="status") @commands.has_permissions(administrator=True) async def log_status(self, ctx: commands.Context): """Shows the current enabled/disabled status for all loggable events.""" guild_id = ctx.guild.id toggles = await settings_manager.get_all_log_event_toggles(guild_id) lines = [] for key in ALL_EVENT_KEYS: is_enabled = toggles.get(key, True) status_emoji = "โœ…" if is_enabled else "โŒ" lines.append(f"{status_emoji} `{key}`") description = "" for line in lines: if len(description) + len(line) + 1 > 4000: view = self._create_log_embed( title=f"Logging Status for {ctx.guild.name}", description=description.strip(), color=discord.Color.blue(), ) await ctx.send(view=view, allowed_mentions=AllowedMentions.none()) description = line + "\n" else: description += line + "\n" if description: view = self._create_log_embed( title=f"Logging Status for {ctx.guild.name}", description=description.strip(), color=discord.Color.blue(), ) await ctx.send(view=view, allowed_mentions=AllowedMentions.none()) @log_group.command(name="list_keys") async def log_list_keys(self, ctx: commands.Context): """Lists all valid event keys for use with the 'log toggle' command.""" keys_text = "\n".join(f"`{key}`" for key in ALL_EVENT_KEYS) if len(keys_text) > 4000: parts = [] current_part = "" for key in ALL_EVENT_KEYS: line = f"`{key}`\n" if len(current_part) + len(line) > 4000: parts.append(current_part) current_part = line else: current_part += line if current_part: parts.append(current_part) first = True for part in parts: view = self._create_log_embed( title="Available Logging Event Keys" if first else "", description=part.strip(), color=discord.Color.purple(), ) await ctx.send(view=view, allowed_mentions=AllowedMentions.none()) first = False else: view = self._create_log_embed( title="Available Logging Event Keys", description=keys_text, color=discord.Color.purple(), ) await ctx.send(view=view, allowed_mentions=AllowedMentions.none()) # --- Thread Events --- @commands.Cog.listener() async def on_thread_create(self, thread: discord.Thread): guild = thread.guild event_key = "thread_create" if not await self._check_log_enabled(guild.id, event_key): return embed = self._create_log_embed( title="๐Ÿงต Thread Created", description=f"Thread {thread.mention} (`{thread.name}`) created in {thread.parent.mention}.", color=discord.Color.dark_blue(), author=thread.owner if thread.owner else None, footer=f"Thread ID: {thread.id} | Parent ID: {thread.parent_id}", ) await self._send_log_embed(guild, embed) @commands.Cog.listener() async def on_thread_delete(self, thread: discord.Thread): guild = thread.guild event_key = "thread_delete" if not await self._check_log_enabled(guild.id, event_key): return embed = self._create_log_embed( title="๐Ÿ—‘๏ธ Thread Deleted", description=f"Thread `{thread.name}` deleted from {thread.parent.mention}.", color=discord.Color.dark_grey(), footer=f"Thread ID: {thread.id} | Parent ID: {thread.parent_id}", ) # Audit log needed for deleter await self._send_log_embed(guild, embed) @commands.Cog.listener() async def on_thread_update(self, before: discord.Thread, after: discord.Thread): guild = after.guild event_key = "thread_update" if not await self._check_log_enabled(guild.id, event_key): return changes = [] if before.name != after.name: changes.append(f"**Name:** `{before.name}` โ†’ `{after.name}`") if before.archived != after.archived: changes.append(f"**Archived:** `{before.archived}` โ†’ `{after.archived}`") if before.locked != after.locked: changes.append(f"**Locked:** `{before.locked}` โ†’ `{after.locked}`") if before.slowmode_delay != after.slowmode_delay: changes.append( f"**Slowmode:** `{before.slowmode_delay}s` โ†’ `{after.slowmode_delay}s`" ) if before.auto_archive_duration != after.auto_archive_duration: changes.append( f"**Auto-Archive:** `{before.auto_archive_duration} mins` โ†’ `{after.auto_archive_duration} mins`" ) if changes: embed = self._create_log_embed( title="๐Ÿ“ Thread Updated", description=f"Thread {after.mention} in {after.parent.mention} updated:\n" + "\n".join(changes), color=discord.Color.blue(), footer=f"Thread ID: {after.id}", ) # Audit log needed for updater await self._send_log_embed(guild, embed) @commands.Cog.listener() async def on_thread_member_join(self, member: discord.ThreadMember): thread = member.thread guild = thread.guild event_key = "thread_member_join" if not await self._check_log_enabled(guild.id, event_key): return user = await self.bot.fetch_user(member.id) # Get user object embed = self._create_log_embed( title="โž• Member Joined Thread", description=f"{self._user_display(user)} joined thread {thread.mention}.", color=discord.Color.dark_green(), author=user, footer=f"Thread ID: {thread.id} | User ID: {user.id}", ) await self._send_log_embed(guild, embed) @commands.Cog.listener() async def on_thread_member_remove(self, member: discord.ThreadMember): thread = member.thread guild = thread.guild event_key = "thread_member_remove" if not await self._check_log_enabled(guild.id, event_key): return user = await self.bot.fetch_user(member.id) # Get user object embed = self._create_log_embed( title="โž– Member Left Thread", description=f"{self._user_display(user)} left thread {thread.mention}.", color=discord.Color.dark_orange(), author=user, footer=f"Thread ID: {thread.id} | User ID: {user.id}", ) await self._send_log_embed(guild, embed) # --- Webhook Events --- @commands.Cog.listener() async def on_webhooks_update(self, channel: discord.abc.GuildChannel): """Logs when webhooks are updated in a channel.""" guild = channel.guild event_key = "webhook_update" if not await self._check_log_enabled(guild.id, event_key): return embed = self._create_log_embed( title="๐ŸŽฃ Webhooks Updated", description=f"Webhooks were updated in channel {channel.mention}.\n*Audit log may contain specific details and updater.*", color=discord.Color.greyple(), footer=f"Channel ID: {channel.id}", ) await self._send_log_embed(guild, embed) # --- Event Listeners --- @commands.Cog.listener() async def on_ready(self): """Initialize when the cog is ready (called after bot on_ready).""" log.info(f"{self.__class__.__name__} cog is ready.") # Initialization is now handled by initialize_cog called from __init__ or start_audit_log_poller_when_ready # Ensure the poller is running if it wasn't started earlier if self.bot.is_ready() and not self.poll_audit_log.is_running(): log.warning( "Poll audit log task was not running after on_ready, attempting to start." ) await self.initialize_cog() # Re-initialize just in case @commands.Cog.listener() async def on_guild_join(self, guild: discord.Guild): """Initialize audit log ID when joining a new guild.""" log.info(f"Joined guild {guild.id}. Initializing audit log ID.") if guild.id not in self.last_audit_log_ids: try: if guild.me.guild_permissions.view_audit_log: async for entry in guild.audit_logs(limit=1): self.last_audit_log_ids[guild.id] = entry.id log.debug( f"Initialized last_audit_log_id for new guild {guild.id} to {entry.id}" ) break else: log.warning( f"Missing 'View Audit Log' permission in new guild {guild.id}." ) self.last_audit_log_ids[guild.id] = None except Exception as e: log.exception( f"Error fetching initial audit log ID for new guild {guild.id}: {e}" ) self.last_audit_log_ids[guild.id] = None @commands.Cog.listener() async def on_guild_remove(self, guild: discord.Guild): """Remove guild data when leaving.""" log.info(f"Left guild {guild.id}. Removing audit log ID.") self.last_audit_log_ids.pop(guild.id, None) # Note: Webhook URL is stored in DB and should ideally be cleaned up there too, # but the guild_settings table uses ON DELETE CASCADE, so it *should* be handled automatically # when the guild is removed from the guilds table in main.py's on_guild_remove. # --- Member Events --- (Keep existing event handlers, they now use _send_log_embed) @commands.Cog.listener() async def on_member_join(self, member: discord.Member): guild = member.guild event_key = "member_join" if not await self._check_log_enabled(guild.id, event_key): return embed = self._create_log_embed( title="๐Ÿ“ฅ Member Joined", description=f"{self._user_display(member)} joined the server.", color=discord.Color.green(), author=member, # Footer already includes User ID via _create_log_embed ) embed.add_field( name="Account Created", value=discord.utils.format_dt(member.created_at, style="F"), inline=False, ) await self._send_log_embed(member.guild, embed) @commands.Cog.listener() async def on_member_remove(self, member: discord.Member): guild = member.guild event_key = "member_remove" if not await self._check_log_enabled(guild.id, event_key): return # This event doesn't tell us if it was a kick or leave. Audit log polling will handle kicks. # We log it as a generic "left" event here. embed = self._create_log_embed( title="๐Ÿ“ค Member Left", description=f"{self._user_display(member)} left the server.", color=discord.Color.orange(), author=member, ) self._add_id_footer(embed, member, id_name="User ID") await self._send_log_embed(member.guild, embed) @commands.Cog.listener() async def on_member_ban( self, guild: discord.Guild, user: Union[discord.User, discord.Member] ): event_key = "member_ban_event" if not await self._check_log_enabled(guild.id, event_key): return # Note: Ban reason isn't available directly in this event. Audit log might have it. embed = self._create_log_embed( title="๐Ÿ”จ Member Banned (Event)", # Clarify this is the event, audit log has more details description=f"{self._user_display(user)} was banned.\n*Audit log may contain moderator and reason.*", color=discord.Color.red(), author=user, # User who was banned ) self._add_id_footer(embed, user, id_name="User ID") await self._send_log_embed(guild, embed) @commands.Cog.listener() async def on_member_unban(self, guild: discord.Guild, user: discord.User): event_key = "member_unban" if not await self._check_log_enabled(guild.id, event_key): return embed = self._create_log_embed( title="๐Ÿ”“ Member Unbanned", description=f"{self._user_display(user)} was unbanned.", color=discord.Color.blurple(), author=user, # User who was unbanned ) self._add_id_footer(embed, user, id_name="User ID") await self._send_log_embed(guild, embed) @commands.Cog.listener() async def on_member_update(self, before: discord.Member, after: discord.Member): guild = after.guild event_key = "member_update" if not await self._check_log_enabled(guild.id, event_key): return changes = [] # Nickname change if before.nick != after.nick: changes.append( f"**Nickname:** `{before.nick or 'None'}` โ†’ `{after.nick or 'None'}`" ) # Role changes (handled more reliably by audit log for who did it) if before.roles != after.roles: added_roles = [r.mention for r in after.roles if r not in before.roles] removed_roles = [r.mention for r in before.roles if r not in after.roles] if added_roles: changes.append(f"**Roles Added:** {', '.join(added_roles)}") if removed_roles: changes.append(f"**Roles Removed:** {', '.join(removed_roles)}") # Timeout change if before.timed_out_until != after.timed_out_until: if after.timed_out_until: timeout_duration = discord.utils.format_dt( after.timed_out_until, style="R" ) changes.append(f"**Timed Out Until:** {timeout_duration}") else: changes.append("**Timeout Removed**") # TODO: Add other trackable changes like status if needed # Add avatar change detection if before.display_avatar != after.display_avatar: changes.append( f"**Avatar Changed**" ) # URL is enough, no need to show old/new if changes: embed = self._create_log_embed( title="๐Ÿ‘ค Member Updated", description=f"{after.mention}\n" + "\n".join(changes), color=discord.Color.yellow(), author=after, ) self._add_id_footer(embed, after, id_name="User ID") await self._send_log_embed(guild, embed) # --- Role Events --- @commands.Cog.listener() async def on_guild_role_create(self, role: discord.Role): guild = role.guild event_key = "role_create_event" if not await self._check_log_enabled(guild.id, event_key): return embed = self._create_log_embed( title="โœจ Role Created (Event)", description=f"Role {role.mention} (`{role.name}`) was created.\n*Audit log may contain creator.*", color=discord.Color.teal(), ) self._add_id_footer(embed, role, id_name="Role ID") await self._send_log_embed(role.guild, embed) @commands.Cog.listener() async def on_guild_role_delete(self, role: discord.Role): guild = role.guild event_key = "role_delete_event" if not await self._check_log_enabled(guild.id, event_key): return embed = self._create_log_embed( title="๐Ÿ—‘๏ธ Role Deleted (Event)", description=f"Role `{role.name}` was deleted.\n*Audit log may contain deleter.*", color=discord.Color.dark_teal(), ) self._add_id_footer(embed, role, id_name="Role ID") await self._send_log_embed(role.guild, embed) @commands.Cog.listener() async def on_guild_role_update(self, before: discord.Role, after: discord.Role): guild = after.guild event_key = "role_update_event" if not await self._check_log_enabled(guild.id, event_key): return changes = [] if before.name != after.name: changes.append(f"**Name:** `{before.name}` โ†’ `{after.name}`") if before.color != after.color: changes.append(f"**Color:** `{before.color}` โ†’ `{after.color}`") if before.hoist != after.hoist: changes.append(f"**Hoisted:** `{before.hoist}` โ†’ `{after.hoist}`") if before.mentionable != after.mentionable: changes.append( f"**Mentionable:** `{before.mentionable}` โ†’ `{after.mentionable}`" ) if before.permissions != after.permissions: # Comparing permissions can be complex, just note that they changed. # Audit log provides specifics on permission changes. changes.append("**Permissions Updated**") # You could compare p.name for p in before.permissions if p.value and not getattr(after.permissions, p.name) etc. # but it gets verbose quickly. # Add position change if before.position != after.position: changes.append(f"**Position:** `{before.position}` โ†’ `{after.position}`") if changes: if ( len(changes) == 1 and changes[0].startswith("**Position:") and not await self._is_recent_audit_log_for_target( guild, discord.AuditLogAction.role_update, after.id ) ): return embed = self._create_log_embed( title="๐Ÿ”ง Role Updated (Event)", description=f"Role {after.mention} updated.\n*Audit log may contain updater and specific permission changes.*\n" + "\n".join(changes), color=discord.Color.blue(), ) self._add_id_footer(embed, after, id_name="Role ID") await self._send_log_embed(guild, embed) # --- Channel Events --- @commands.Cog.listener() async def on_guild_channel_create(self, channel: discord.abc.GuildChannel): guild = channel.guild event_key = "channel_create_event" if not await self._check_log_enabled(guild.id, event_key): return ch_type = str(channel.type).capitalize() embed = self._create_log_embed( title=f"โž• {ch_type} Channel Created (Event)", description=f"Channel {channel.mention} (`{channel.name}`) was created.\n*Audit log may contain creator.*", color=discord.Color.green(), ) self._add_id_footer(embed, channel, id_name="Channel ID") await self._send_log_embed(channel.guild, embed) @commands.Cog.listener() async def on_guild_channel_delete(self, channel: discord.abc.GuildChannel): guild = channel.guild event_key = "channel_delete_event" if not await self._check_log_enabled(guild.id, event_key): return ch_type = str(channel.type).capitalize() embed = self._create_log_embed( title=f"โž– {ch_type} Channel Deleted (Event)", description=f"Channel `{channel.name}` was deleted.\n*Audit log may contain deleter.*", color=discord.Color.red(), ) self._add_id_footer(embed, channel, id_name="Channel ID") await self._send_log_embed(channel.guild, embed) @commands.Cog.listener() async def on_guild_channel_update( self, before: discord.abc.GuildChannel, after: discord.abc.GuildChannel ): guild = after.guild event_key = "channel_update_event" if not await self._check_log_enabled(guild.id, event_key): return changes = [] ch_type = str(after.type).capitalize() if before.name != after.name: changes.append(f"**Name:** `{before.name}` โ†’ `{after.name}`") if isinstance(before, discord.TextChannel) and isinstance( after, discord.TextChannel ): if before.topic != after.topic: changes.append( f"**Topic:** `{before.topic or 'None'}` โ†’ `{after.topic or 'None'}`" ) if before.slowmode_delay != after.slowmode_delay: changes.append( f"**Slowmode:** `{before.slowmode_delay}s` โ†’ `{after.slowmode_delay}s`" ) if before.nsfw != after.nsfw: changes.append(f"**NSFW:** `{before.nsfw}` โ†’ `{after.nsfw}`") if isinstance(before, discord.VoiceChannel) and isinstance( after, discord.VoiceChannel ): if before.bitrate != after.bitrate: changes.append(f"**Bitrate:** `{before.bitrate}` โ†’ `{after.bitrate}`") if before.user_limit != after.user_limit: changes.append( f"**User Limit:** `{before.user_limit}` โ†’ `{after.user_limit}`" ) # Permission overwrites change if before.overwrites != after.overwrites: # Identify changes without detailing every permission bit before_targets = set(before.overwrites.keys()) after_targets = set(after.overwrites.keys()) added_targets = after_targets - before_targets removed_targets = before_targets - after_targets updated_targets = before_targets.intersection( after_targets ) # Targets present before and after overwrite_changes = [] if added_targets: overwrite_changes.append( f"Added overwrites for: {', '.join([f'<@{t.id}>' if isinstance(t, discord.Member) else f'<@&{t.id}>' for t in added_targets])}" ) if removed_targets: overwrite_changes.append( f"Removed overwrites for: {', '.join([f'<@{t.id}>' if isinstance(t, discord.Member) else f'<@&{t.id}>' for t in removed_targets])}" ) # Check if any *values* changed for targets present both before and after if any( before.overwrites[t] != after.overwrites[t] for t in updated_targets ): overwrite_changes.append( f"Modified overwrites for: {', '.join([f'<@{t.id}>' if isinstance(t, discord.Member) else f'<@&{t.id}>' for t in updated_targets if before.overwrites[t] != after.overwrites[t]])}" ) if overwrite_changes: changes.append( f"**Permission Overwrites:**\n - " + "\n - ".join(overwrite_changes) ) else: changes.append( "**Permission Overwrites Updated** (No specific target changes detected by event)" ) # Add position change if before.position != after.position: changes.append(f"**Position:** `{before.position}` โ†’ `{after.position}`") # Add category change if before.category != after.category: before_cat = before.category.mention if before.category else "None" after_cat = after.category.mention if after.category else "None" changes.append(f"**Category:** {before_cat} โ†’ {after_cat}") if changes: if ( len(changes) == 1 and changes[0].startswith("**Position:") and not await self._is_recent_audit_log_for_target( guild, discord.AuditLogAction.channel_update, after.id ) ): return embed = self._create_log_embed( title=f"๐Ÿ“ {ch_type} Channel Updated (Event)", description=f"Channel {after.mention} updated.\n*Audit log may contain updater and specific permission changes.*\n" + "\n".join(changes), color=discord.Color.yellow(), ) self._add_id_footer(embed, after, id_name="Channel ID") await self._send_log_embed(guild, embed) # --- Message Events --- @commands.Cog.listener() async def on_message_edit(self, before: discord.Message, after: discord.Message): # Ignore edits from bots or if content is the same (e.g., embed loading) if before.author.bot or before.content == after.content: return guild = after.guild if not guild: return # Ignore DMs # Check if logging is enabled *after* initial checks event_key = "message_edit" if not await self._check_log_enabled(guild.id, event_key): return emoji, color = EVENT_STYLES.get( "message_edit", ("", discord.Color.light_grey()) ) embed = self._create_log_embed( title=f"{emoji} Message Edited", description=f"Message edited in {after.channel.mention}", color=color, author=after.author, ) diff = "\n".join( difflib.ndiff(before.content.splitlines(), after.content.splitlines()) ) if len(diff) > 1000: diff = diff[:997] + "..." embed.add_field(name="Jump", value=f"[Link]({after.jump_url})") embed.add_field( name="Changes", value=( f"```diff\n{diff}\n```" if diff.strip() else "`(only embeds/attachments changed)`" ), inline=False, ) if before.content: before_text = before.content[:1020] + ( "..." if len(before.content) > 1020 else "" ) embed.add_field(name="Before", value=before_text or "`Empty`", inline=False) if after.content: after_text = after.content[:1020] + ( "..." if len(after.content) > 1020 else "" ) embed.add_field(name="After", value=after_text or "`Empty`", inline=False) self._add_id_footer(embed, after, id_name="Message ID") # Add message ID await self._send_log_embed(guild, embed) @commands.Cog.listener() async def on_message_delete(self, message: discord.Message): # Ignore deletes from bots or messages without content/embeds/attachments if message.author.bot or ( not message.content and not message.embeds and not message.attachments ): # Allow logging bot message deletions if needed, but can be noisy # Example: if message.author.id == self.bot.user.id: pass # Log bot's own deletions # else: return return guild = message.guild if not guild: return # Ignore DMs # Check if logging is enabled *after* initial checks event_key = "message_delete" if not await self._check_log_enabled(guild.id, event_key): return emoji, color = EVENT_STYLES.get( "message_delete", ("", discord.Color.dark_grey()) ) embed = self._create_log_embed( title=f"{emoji} Message Deleted", description=f"Message deleted in {message.channel.mention}", color=color, author=message.author, ) embed.add_field(name="Jump", value=f"[Link]({message.jump_url})") if message.content: embed.add_field( name="Content", value=message.content[:1020] + ("..." if len(message.content) > 1020 else "") or "`Empty Message`", inline=False, ) if message.attachments: atts = [f"[{att.filename}]({att.url})" for att in message.attachments] embed.add_field(name="Attachments", value=", ".join(atts), inline=False) self._add_id_footer(embed, message, id_name="Message ID") # Add message ID await self._send_log_embed(guild, embed) # --- Reaction Events --- @commands.Cog.listener() async def on_reaction_add( self, reaction: discord.Reaction, user: Union[discord.Member, discord.User] ): if user.bot: return guild = reaction.message.guild if not guild: return # Should not happen in guilds but safety check # Check if logging is enabled *after* initial checks event_key = "reaction_add" if not await self._check_log_enabled(guild.id, event_key): return embed = self._create_log_embed( title="๐Ÿ‘ Reaction Added", description=f"{self._user_display(user)} added {reaction.emoji} to a message by {self._user_display(reaction.message.author)} in {reaction.message.channel.mention} [Jump to Message]({reaction.message.jump_url})", color=discord.Color.gold(), author=user, ) self._add_id_footer(embed, reaction.message, id_name="Message ID") await self._send_log_embed(guild, embed) @commands.Cog.listener() async def on_reaction_remove( self, reaction: discord.Reaction, user: Union[discord.Member, discord.User] ): if user.bot: return guild = reaction.message.guild if not guild: return # Should not happen in guilds but safety check # Check if logging is enabled *after* initial checks event_key = "reaction_remove" if not await self._check_log_enabled(guild.id, event_key): return embed = self._create_log_embed( title="๐Ÿ‘Ž Reaction Removed", description=f"{self._user_display(user)} removed {reaction.emoji} from a message by {self._user_display(reaction.message.author)} in {reaction.message.channel.mention} [Jump to Message]({reaction.message.jump_url})", color=discord.Color.dark_gold(), author=user, ) self._add_id_footer(embed, reaction.message, id_name="Message ID") await self._send_log_embed(guild, embed) @commands.Cog.listener() async def on_reaction_clear( self, message: discord.Message, _: list[discord.Reaction] ): guild = message.guild if not guild: return # Should not happen in guilds but safety check # Check if logging is enabled *after* initial checks event_key = "reaction_clear" if not await self._check_log_enabled(guild.id, event_key): return embed = self._create_log_embed( title="๐Ÿ’ฅ All Reactions Cleared", description=f"All reactions were cleared from a message by {message.author.mention} in {message.channel.mention} [Jump to Message]({message.jump_url})\n*Audit log may contain moderator.*", color=discord.Color.orange(), author=message.author, # Usually the author or a mod clears reactions ) self._add_id_footer(embed, message, id_name="Message ID") await self._send_log_embed(guild, embed) @commands.Cog.listener() async def on_reaction_clear_emoji(self, reaction: discord.Reaction): guild = reaction.message.guild if not guild: return # Should not happen in guilds but safety check # Check if logging is enabled *after* initial checks event_key = "reaction_clear_emoji" if not await self._check_log_enabled(guild.id, event_key): return embed = self._create_log_embed( title="๐Ÿ’ฅ Emoji Reactions Cleared", description=f"All {reaction.emoji} reactions were cleared from a message by {reaction.message.author.mention} in {reaction.message.channel.mention} [Jump to Message]({reaction.message.jump_url})\n*Audit log may contain moderator.*", color=discord.Color.dark_orange(), author=reaction.message.author, # Usually the author or a mod clears reactions ) self._add_id_footer(embed, reaction.message, id_name="Message ID") await self._send_log_embed(guild, embed) # --- Voice State Events --- @commands.Cog.listener() async def on_voice_state_update( self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState, ): guild = member.guild event_key = "voice_state_update" if not await self._check_log_enabled(guild.id, event_key): return action = "" details = "" color = discord.Color.purple() # Join VC if before.channel is None and after.channel is not None: action = "๐Ÿ”Š Joined Voice Channel" details = f"Joined {after.channel.mention}" color = discord.Color.green() # Leave VC elif before.channel is not None and after.channel is None: action = "๐Ÿ”‡ Left Voice Channel" details = f"Left {before.channel.mention}" color = discord.Color.orange() # Move VC elif ( before.channel is not None and after.channel is not None and before.channel != after.channel ): action = "๐Ÿ”„ Moved Voice Channel" details = f"Moved from {before.channel.mention} to {after.channel.mention}" color = discord.Color.blue() # Server Mute/Deafen Update elif before.mute != after.mute: action = "๐ŸŽ™๏ธ Server Mute Update" details = f"Server Muted: `{after.mute}`" color = discord.Color.red() if after.mute else discord.Color.green() elif before.deaf != after.deaf: action = "๐ŸŽง Server Deafen Update" details = f"Server Deafened: `{after.deaf}`" color = discord.Color.red() if after.deaf else discord.Color.green() # Self Mute/Deafen Update (Can be noisy) # elif before.self_mute != after.self_mute: # action = "๐ŸŽ™๏ธ Self Mute Update" # details = f"Self Muted: `{after.self_mute}`" # elif before.self_deaf != after.self_deaf: # action = "๐ŸŽง Self Deafen Update" # details = f"Self Deafened: `{after.self_deaf}`" # Stream Update (Can be noisy) # elif before.self_stream != after.self_stream: # action = "๐Ÿ“น Streaming Update" # details = f"Streaming: `{after.self_stream}`" # Video Update (Can be noisy) # elif before.self_video != after.self_video: # action = " Webcam Update" # details = f"Webcam On: `{after.self_video}`" else: return # No relevant change detected embed = self._create_log_embed( title=action, description=f"{self._user_display(member)}\n{details}", color=color, author=member, ) self._add_id_footer(embed, member, id_name="User ID") await self._send_log_embed(guild, embed) # --- Guild/Server Events --- @commands.Cog.listener() async def on_guild_update(self, before: discord.Guild, after: discord.Guild): guild = after # Use 'after' for guild ID check event_key = "guild_update_event" if not await self._check_log_enabled(guild.id, event_key): return changes = [] if before.name != after.name: changes.append(f"**Name:** `{before.name}` โ†’ `{after.name}`") if before.description != after.description: changes.append( f"**Description:** `{before.description or 'None'}` โ†’ `{after.description or 'None'}`" ) if before.icon != after.icon: changes.append(f"**Icon Changed**") # URL comparison can be tricky if before.banner != after.banner: changes.append(f"**Banner Changed**") if before.owner != after.owner: changes.append( f"**Owner:** {before.owner.mention if before.owner else 'None'} โ†’ {after.owner.mention if after.owner else 'None'}" ) # Add other relevant changes: region, verification_level, explicit_content_filter, etc. if before.verification_level != after.verification_level: changes.append( f"**Verification Level:** `{before.verification_level}` โ†’ `{after.verification_level}`" ) if before.explicit_content_filter != after.explicit_content_filter: changes.append( f"**Explicit Content Filter:** `{before.explicit_content_filter}` โ†’ `{after.explicit_content_filter}`" ) if before.system_channel != after.system_channel: changes.append( f"**System Channel:** {before.system_channel.mention if before.system_channel else 'None'} โ†’ {after.system_channel.mention if after.system_channel else 'None'}" ) if changes: embed = self._create_log_embed( # title="โš™๏ธ Guild Updated", # Removed duplicate title title="โš™๏ธ Guild Updated (Event)", description="Server settings were updated.\n*Audit log may contain updater.*\n" + "\n".join(changes), color=discord.Color.dark_purple(), ) self._add_id_footer(embed, after, id_name="Guild ID") await self._send_log_embed(after, embed) @commands.Cog.listener() async def on_guild_emojis_update( self, guild: discord.Guild, before: tuple[discord.Emoji, ...], after: tuple[discord.Emoji, ...], ): event_key = "emoji_update_event" if not await self._check_log_enabled(guild.id, event_key): return added = [e for e in after if e not in before] removed = [e for e in before if e not in after] # Renamed detection is harder, requires comparing by ID renamed_before = [] renamed_after = [] before_map = {e.id: e for e in before} after_map = {e.id: e for e in after} for e_id, e_after in after_map.items(): if e_id in before_map and before_map[e_id].name != e_after.name: renamed_before.append(before_map[e_id]) renamed_after.append(e_after) desc = "" if added: desc += f"**Added:** {', '.join([str(e) for e in added])}\n" if removed: desc += f"**Removed:** {', '.join([f'`{e.name}`' for e in removed])}\n" # Can't display removed emoji easily if renamed_before: desc += "**Renamed:**\n" + "\n".join( [f"`{b.name}` โ†’ {a}" for b, a in zip(renamed_before, renamed_after)] ) if desc: embed = self._create_log_embed( # title="๐Ÿ˜€ Emojis Updated", # Removed duplicate title title="๐Ÿ˜€ Emojis Updated (Event)", description=f"*Audit log may contain updater.*\n{desc.strip()}", color=discord.Color.magenta(), ) self._add_id_footer(embed, guild, id_name="Guild ID") await self._send_log_embed(guild, embed) # --- Invite Events --- @commands.Cog.listener() async def on_invite_create(self, invite: discord.Invite): guild = invite.guild if not guild: return # Check if logging is enabled *after* initial checks event_key = "invite_create_event" if not await self._check_log_enabled(guild.id, event_key): return inviter = invite.inviter channel = invite.channel desc = f"Invite `{invite.code}` created for {channel.mention if channel else 'Unknown Channel'}" if invite.max_age: # Use invite.created_at if available, otherwise fall back to current time created_time = ( invite.created_at if invite.created_at is not None else discord.utils.utcnow() ) expires_at = created_time + datetime.timedelta(seconds=invite.max_age) desc += f"\nExpires: {discord.utils.format_dt(expires_at, style='R')}" if invite.max_uses: desc += f"\nMax Uses: {invite.max_uses}" embed = self._create_log_embed( # title="โœ‰๏ธ Invite Created", # Removed duplicate title title="โœ‰๏ธ Invite Created (Event)", description=f"{desc}\n*Audit log may contain creator.*", color=discord.Color.dark_magenta(), author=inviter, # Can be None if invite created through server settings/vanity URL ) self._add_id_footer( embed, invite, obj_id=invite.id, id_name="Invite ID" ) # Invite object doesn't have ID directly? Use code? No, ID exists. await self._send_log_embed(guild, embed) @commands.Cog.listener() async def on_invite_delete(self, invite: discord.Invite): guild = invite.guild if not guild: return # Check if logging is enabled *after* initial checks event_key = "invite_delete_event" if not await self._check_log_enabled(guild.id, event_key): return channel = invite.channel desc = f"Invite `{invite.code}` for {channel.mention if channel else 'Unknown Channel'} was deleted or expired." embed = self._create_log_embed( # title="๐Ÿ—‘๏ธ Invite Deleted", # Removed duplicate title title="๐Ÿ—‘๏ธ Invite Deleted (Event)", description=f"{desc}\n*Audit log may contain deleter.*", color=discord.Color.dark_grey(), # Cannot reliably get inviter after deletion ) # Invite object might not have ID after deletion, use code in footer? embed.set_footer(f"Invite Code: {invite.code}") await self._send_log_embed(guild, embed) # --- Bot/Command Events --- # Note: These might be noisy depending on bot usage. Consider enabling selectively. # @commands.Cog.listener() # async def on_command(self, ctx: commands.Context): # if not ctx.guild: return # Ignore DMs # embed = self._create_log_embed( # title="โ–ถ๏ธ Command Used", # description=f"`{ctx.command.qualified_name}` used by {ctx.author.mention} in {ctx.channel.mention}", # color=discord.Color.lighter_grey(), # author=ctx.author # ) # await self._send_log_embed(ctx.guild, embed) @commands.Cog.listener() async def on_command_error( self, ctx: commands.Context, error: commands.CommandError ): # Log only significant errors, ignore things like CommandNotFound or CheckFailure if desired ignored = ( commands.CommandNotFound, commands.CheckFailure, commands.UserInputError, commands.DisabledCommand, commands.CommandOnCooldown, ) if isinstance(error, ignored): return if not ctx.guild: return # Ignore DMs # Check if logging is enabled *after* initial checks event_key = "command_error" if not await self._check_log_enabled(ctx.guild.id, event_key): return embed = self._create_log_embed( title="โŒ Command Error", description=f"Error in command `{ctx.command.qualified_name if ctx.command else 'Unknown'}` used by {ctx.author.mention} in {ctx.channel.mention}", color=discord.Color.brand_red(), author=ctx.author, ) # Get traceback if available (might need error handling specific to your bot's setup) import traceback tb = "".join( traceback.format_exception(type(error), error, error.__traceback__) ) embed.add_field( name="Error Details", value=( f"```py\n{tb[:1000]}\n...```" if len(tb) > 1000 else f"```py\n{tb}```" ), inline=False, ) await self._send_log_embed(ctx.guild, embed) # @commands.Cog.listener() # async def on_command_completion(self, ctx: commands.Context): # if not ctx.guild: return # Ignore DMs # embed = self._create_log_embed( # title="โœ… Command Completed", # description=f"`{ctx.command.qualified_name}` completed successfully for {ctx.author.mention} in {ctx.channel.mention}", # color=discord.Color.dark_green(), # author=ctx.author # ) # if await self._check_log_enabled(ctx.guild.id, "command_completion"): # Add toggle check if uncommented # await self._send_log_embed(ctx.guild, embed) # Note: Duplicate Thread/Webhook listeners removed below this line. # The first set of definitions already includes the toggle checks. # --- Audit Log Polling Task --- @tasks.loop(seconds=60) # Poll every 60 seconds async def poll_audit_log(self): # This loop starts only after the bot is ready and initialized if not self.bot.is_ready() or self.session is None or self.session.closed: # log.debug("Audit log poll skipped: Bot not ready or session not initialized.") return # Wait until ready and session is available # log.debug("Polling audit logs for all guilds...") # Can be noisy for guild in self.bot.guilds: guild_id = guild.id # Skip polling if webhook isn't configured for this guild if not await settings_manager.get_logging_webhook(guild_id): # log.debug(f"Skipping audit log poll for guild {guild_id}: Logging webhook not configured.") continue # Check permissions and last known ID for this specific guild if not guild.me.guild_permissions.view_audit_log: if ( self.last_audit_log_ids.get(guild_id) is not None ): # Log only once when perms are lost log.warning( f"Missing 'View Audit Log' permission in guild {guild_id}. Cannot poll audit log." ) self.last_audit_log_ids[guild_id] = None # Mark as unable to poll continue # Skip this guild # If we previously couldn't poll due to permissions, try re-initializing the ID if ( self.last_audit_log_ids.get(guild_id) is None and guild.me.guild_permissions.view_audit_log ): log.info( f"Re-initializing audit log ID for guild {guild_id} after gaining permissions." ) try: async for entry in guild.audit_logs(limit=1): self.last_audit_log_ids[guild_id] = entry.id log.debug( f"Re-initialized last_audit_log_id for guild {guild.id} to {entry.id}" ) break except Exception as e: log.exception( f"Error re-initializing audit log ID for guild {guild.id}: {e}" ) continue # Skip this cycle if re-init fails last_id = self.last_audit_log_ids.get(guild_id) # log.debug(f"Polling audit log for guild {guild_id} after ID: {last_id}") # Can be noisy relevant_actions = [ discord.AuditLogAction.kick, discord.AuditLogAction.member_prune, discord.AuditLogAction.member_role_update, discord.AuditLogAction.message_delete, discord.AuditLogAction.message_bulk_delete, discord.AuditLogAction.member_update, # Includes timeout discord.AuditLogAction.role_create, discord.AuditLogAction.role_delete, discord.AuditLogAction.role_update, discord.AuditLogAction.channel_create, discord.AuditLogAction.channel_delete, discord.AuditLogAction.channel_update, discord.AuditLogAction.emoji_create, discord.AuditLogAction.emoji_delete, discord.AuditLogAction.emoji_update, discord.AuditLogAction.invite_create, discord.AuditLogAction.invite_delete, discord.AuditLogAction.guild_update, discord.AuditLogAction.ban, # Add ban action for reason/moderator discord.AuditLogAction.unban, # Add unban action for moderator ] latest_id_in_batch = last_id entries_to_log = [] try: # Fetch entries after the last known ID for this guild # The 'actions' parameter is deprecated; filter manually after fetching. async for entry in guild.audit_logs( limit=50, after=discord.Object(id=last_id) if last_id else None ): # log.debug(f"Processing audit entry {entry.id} for guild {guild_id}") # Debug print # Double check ID comparison just in case the 'after' parameter isn't perfectly reliable across different calls/times if last_id is None or entry.id > last_id: entries_to_log.append(entry) if latest_id_in_batch is None or entry.id > latest_id_in_batch: latest_id_in_batch = entry.id # Process entries oldest to newest to maintain order for entry in reversed(entries_to_log): # Filter by action *after* fetching if entry.action in relevant_actions: await self._process_audit_log_entry(guild, entry) # Update the last seen ID for this guild *after* processing the batch if latest_id_in_batch is not None and latest_id_in_batch != last_id: self.last_audit_log_ids[guild_id] = latest_id_in_batch # log.debug(f"Updated last_audit_log_id for guild {guild_id} to {latest_id_in_batch}") # Debug print except discord.Forbidden: log.warning( f"Missing permissions (likely View Audit Log) in guild {guild.id} during poll. Marking as unable." ) self.last_audit_log_ids[guild_id] = None # Mark as unable to poll except discord.HTTPException as e: log.error( f"HTTP error fetching audit logs for guild {guild.id}: {e}. Retrying next cycle." ) # Consider adding backoff logic here if errors persist except Exception as e: log.exception( f"Unexpected error in poll_audit_log for guild {guild.id}: {e}" ) # Don't update last_audit_log_id on unexpected error, retry next time async def _process_audit_log_entry( self, guild: discord.Guild, entry: discord.AuditLogEntry ): """Processes a single relevant audit log entry and sends an embed.""" user = entry.user # Moderator/Actor target = entry.target # User/Channel/Role/Message affected reason = entry.reason action_desc = "" color = discord.Color.dark_grey() title = f"๐Ÿ›ก๏ธ Audit Log: {str(entry.action).replace('_', ' ').title()}" if not user: # Should generally not happen for manual actions, but safeguard return # --- Member Events (Ban, Unban, Kick, Prune) --- if entry.action == discord.AuditLogAction.ban: audit_event_key = "audit_ban" if not await self._check_log_enabled(guild.id, audit_event_key): return title = "๐Ÿ›ก๏ธ Audit Log: Member Banned" action_desc = ( f"{self._user_display(user)} banned {self._user_display(target)}" ) color = discord.Color.red() # self._add_id_footer(embed, target, id_name="Target ID") # Footer set later elif entry.action == discord.AuditLogAction.unban: audit_event_key = "audit_unban" if not await self._check_log_enabled(guild.id, audit_event_key): return title = "๐Ÿ›ก๏ธ Audit Log: Member Unbanned" action_desc = ( f"{self._user_display(user)} unbanned {self._user_display(target)}" ) color = discord.Color.blurple() # self._add_id_footer(embed, target, id_name="Target ID") # Footer set later elif entry.action == discord.AuditLogAction.kick: audit_event_key = "audit_kick" if not await self._check_log_enabled(guild.id, audit_event_key): return title = "๐Ÿ›ก๏ธ Audit Log: Member Kicked" action_desc = ( f"{self._user_display(user)} kicked {self._user_display(target)}" ) color = discord.Color.brand_red() # self._add_id_footer(embed, target, id_name="Target ID") # Footer set later elif entry.action == discord.AuditLogAction.member_prune: audit_event_key = "audit_prune" if not await self._check_log_enabled(guild.id, audit_event_key): return title = "๐Ÿ›ก๏ธ Audit Log: Member Prune" days = entry.extra.get("delete_member_days") count = entry.extra.get("members_removed") action_desc = f"{self._user_display(user)} pruned {count} members inactive for {days} days." color = discord.Color.dark_red() # No specific target ID here # --- Member Update (Roles, Timeout) --- elif entry.action == discord.AuditLogAction.member_role_update: audit_event_key = "audit_member_role_update" if not await self._check_log_enabled(guild.id, audit_event_key): return # entry.before.roles / entry.after.roles contains the role changes before_roles = entry.before.roles after_roles = entry.after.roles added = [r.mention for r in after_roles if r not in before_roles] removed = [r.mention for r in before_roles if r not in after_roles] if added or removed: # Only log if roles actually changed action_desc = f"{self._user_display(user)} updated roles for {self._user_display(target)}:" if added: action_desc += f"\n**Added:** {', '.join(added)}" if removed: action_desc += f"\n**Removed:** {', '.join(removed)}" color = discord.Color.blue() else: return # Skip if no role change detected elif entry.action == discord.AuditLogAction.member_update: # Check for timeout changes before_timed_out = getattr(entry.before, "timed_out_until", None) after_timed_out = getattr(entry.after, "timed_out_until", None) if before_timed_out != after_timed_out: audit_event_key = "audit_member_update_timeout" if not await self._check_log_enabled(guild.id, audit_event_key): return title = "๐Ÿ›ก๏ธ Audit Log: Member Timeout Update" if after_timed_out: timeout_duration = discord.utils.format_dt( after_timed_out, style="R" ) action_desc = f"{self._user_display(user)} timed out {self._user_display(target)} until {timeout_duration}" color = discord.Color.orange() else: action_desc = f"{self._user_display(user)} removed timeout from {self._user_display(target)}" color = discord.Color.green() # self._add_id_footer(embed, target, id_name="Target ID") # Footer set later else: # Could log other member updates here if needed (e.g. nick changes by mods) - requires separate toggle key # log.debug(f"Unhandled member_update audit log entry by {user} on {target}") return # Skip other member updates for now # --- Role Events --- elif entry.action == discord.AuditLogAction.role_create: audit_event_key = "audit_role_create" if not await self._check_log_enabled(guild.id, audit_event_key): return title = "๐Ÿ›ก๏ธ Audit Log: Role Created" role = target # Target is the role action_desc = f"{user.mention} created role {role.mention} (`{role.name}`)" color = discord.Color.teal() # self._add_id_footer(embed, role, id_name="Role ID") # Footer set later elif entry.action == discord.AuditLogAction.role_delete: audit_event_key = "audit_role_delete" if not await self._check_log_enabled(guild.id, audit_event_key): return title = "๐Ÿ›ก๏ธ Audit Log: Role Deleted" # Target is the role ID, before object has role details role_name = entry.before.name role_id = entry.target.id action_desc = f"{user.mention} deleted role `{role_name}` ({role_id})" color = discord.Color.dark_teal() # self._add_id_footer(embed, obj_id=role_id, id_name="Role ID") # Footer set later elif entry.action == discord.AuditLogAction.role_update: audit_event_key = "audit_role_update" if not await self._check_log_enabled(guild.id, audit_event_key): return title = "๐Ÿ›ก๏ธ Audit Log: Role Updated" role = target changes = [] # Simple diffing for common properties if ( hasattr(entry.before, "name") and hasattr(entry.after, "name") and entry.before.name != entry.after.name ): changes.append(f"Name: `{entry.before.name}` โ†’ `{entry.after.name}`") if ( hasattr(entry.before, "color") and hasattr(entry.after, "color") and entry.before.color != entry.after.color ): changes.append(f"Color: `{entry.before.color}` โ†’ `{entry.after.color}`") if ( hasattr(entry.before, "hoist") and hasattr(entry.after, "hoist") and entry.before.hoist != entry.after.hoist ): changes.append( f"Hoisted: `{entry.before.hoist}` โ†’ `{entry.after.hoist}`" ) if ( hasattr(entry.before, "mentionable") and hasattr(entry.after, "mentionable") and entry.before.mentionable != entry.after.mentionable ): changes.append( f"Mentionable: `{entry.before.mentionable}` โ†’ `{entry.after.mentionable}`" ) if ( hasattr(entry.before, "permissions") and hasattr(entry.after, "permissions") and entry.before.permissions != entry.after.permissions ): changes.append( "Permissions Updated (See Audit Log for details)" ) # Permissions are complex if changes: action_desc = ( f"{user.mention} updated role {role.mention} ({role.id}):\n" + "\n".join(f"- {c}" for c in changes) ) color = discord.Color.blue() # self._add_id_footer(embed, role, id_name="Role ID") # Footer set later else: # log.debug(f"Role update detected for {role.id} but no tracked changes found.") # Might still want to log permission changes even if other props are same return # Skip if no changes we track were made # --- Channel Events --- elif entry.action == discord.AuditLogAction.channel_create: audit_event_key = "audit_channel_create" if not await self._check_log_enabled(guild.id, audit_event_key): return title = "๐Ÿ›ก๏ธ Audit Log: Channel Created" channel = target ch_type = str(channel.type).capitalize() action_desc = f"{user.mention} created {ch_type} channel {channel.mention} (`{channel.name}`)" color = discord.Color.green() # self._add_id_footer(embed, channel, id_name="Channel ID") # Footer set later elif entry.action == discord.AuditLogAction.channel_delete: audit_event_key = "audit_channel_delete" if not await self._check_log_enabled(guild.id, audit_event_key): return title = "๐Ÿ›ก๏ธ Audit Log: Channel Deleted" # Target is channel ID, before object has details channel_name = entry.before.name channel_id = entry.target.id ch_type = str(entry.before.type).capitalize() action_desc = f"{user.mention} deleted {ch_type} channel `{channel_name}` ({channel_id})" color = discord.Color.red() # self._add_id_footer(embed, obj_id=channel_id, id_name="Channel ID") # Footer set later elif entry.action == discord.AuditLogAction.channel_update: audit_event_key = "audit_channel_update" if not await self._check_log_enabled(guild.id, audit_event_key): return title = "๐Ÿ›ก๏ธ Audit Log: Channel Updated" channel = target ch_type = str(channel.type).capitalize() changes = [] # Simple diffing if ( hasattr(entry.before, "name") and hasattr(entry.after, "name") and entry.before.name != entry.after.name ): changes.append(f"Name: `{entry.before.name}` โ†’ `{entry.after.name}`") if ( hasattr(entry.before, "topic") and hasattr(entry.after, "topic") and entry.before.topic != entry.after.topic ): changes.append(f"Topic Changed") # Keep it simple if ( hasattr(entry.before, "nsfw") and hasattr(entry.after, "nsfw") and entry.before.nsfw != entry.after.nsfw ): changes.append(f"NSFW: `{entry.before.nsfw}` โ†’ `{entry.after.nsfw}`") if ( hasattr(entry.before, "slowmode_delay") and hasattr(entry.after, "slowmode_delay") and entry.before.slowmode_delay != entry.after.slowmode_delay ): changes.append( f"Slowmode: `{entry.before.slowmode_delay}s` โ†’ `{entry.after.slowmode_delay}s`" ) if ( hasattr(entry.before, "bitrate") and hasattr(entry.after, "bitrate") and entry.before.bitrate != entry.after.bitrate ): changes.append( f"Bitrate: `{entry.before.bitrate}` โ†’ `{entry.after.bitrate}`" ) # Process detailed changes from entry.changes detailed_changes = [] # AuditLogChanges is not directly iterable, so we need to handle it differently try: # Check if entry.changes has the __iter__ attribute (is iterable) if hasattr(entry.changes, "__iter__"): for change in entry.changes: attr = change.attribute before_val = change.before after_val = change.after if attr == "name": detailed_changes.append( f"Name: `{before_val}` โ†’ `{after_val}`" ) elif attr == "topic": detailed_changes.append( f"Topic: `{before_val or 'None'}` โ†’ `{after_val or 'None'}`" ) elif attr == "nsfw": detailed_changes.append( f"NSFW: `{before_val}` โ†’ `{after_val}`" ) elif attr == "slowmode_delay": detailed_changes.append( f"Slowmode: `{before_val}s` โ†’ `{after_val}s`" ) elif attr == "bitrate": detailed_changes.append( f"Bitrate: `{before_val}` โ†’ `{after_val}`" ) elif attr == "user_limit": detailed_changes.append( f"User Limit: `{before_val}` โ†’ `{after_val}`" ) elif attr == "position": detailed_changes.append( f"Position: `{before_val}` โ†’ `{after_val}`" ) elif attr == "category": detailed_changes.append( f"Category: {getattr(before_val, 'mention', 'None')} โ†’ {getattr(after_val, 'mention', 'None')}" ) elif attr == "permission_overwrites": # Audit log gives overwrite target ID and type directly in the change object ow_target_id = getattr( change.target, "id", None ) # Target of the overwrite change ow_target_type = getattr( change.target, "type", None ) # 'role' or 'member' if ow_target_id and ow_target_type: target_mention = ( f"<@&{ow_target_id}>" if ow_target_type == "role" else f"<@{ow_target_id}>" ) # Determine if added, removed, or updated (before/after values are PermissionOverwrite objects) if before_val is None and after_val is not None: detailed_changes.append( f"Added overwrite for {target_mention}" ) elif before_val is not None and after_val is None: detailed_changes.append( f"Removed overwrite for {target_mention}" ) else: detailed_changes.append( f"Updated overwrite for {target_mention}" ) else: detailed_changes.append( "Permission Overwrites Updated (Target details unavailable)" ) # Fallback else: # Log other unhandled changes generically detailed_changes.append( f"{attr.replace('_', ' ').title()} changed: `{before_val}` โ†’ `{after_val}`" ) else: # Handle AuditLogChanges as a non-iterable object # We can access the before and after attributes directly if hasattr(entry.changes, "before") and hasattr( entry.changes, "after" ): before = entry.changes.before after = entry.changes.after # Compare attributes between before and after if ( hasattr(before, "name") and hasattr(after, "name") and before.name != after.name ): detailed_changes.append( f"Name: `{before.name}` โ†’ `{after.name}`" ) if ( hasattr(before, "topic") and hasattr(after, "topic") and before.topic != after.topic ): detailed_changes.append( f"Topic: `{before.topic or 'None'}` โ†’ `{after.topic or 'None'}`" ) if ( hasattr(before, "nsfw") and hasattr(after, "nsfw") and before.nsfw != after.nsfw ): detailed_changes.append( f"NSFW: `{before.nsfw}` โ†’ `{after.nsfw}`" ) if ( hasattr(before, "slowmode_delay") and hasattr(after, "slowmode_delay") and before.slowmode_delay != after.slowmode_delay ): detailed_changes.append( f"Slowmode: `{before.slowmode_delay}s` โ†’ `{after.slowmode_delay}s`" ) if ( hasattr(before, "bitrate") and hasattr(after, "bitrate") and before.bitrate != after.bitrate ): detailed_changes.append( f"Bitrate: `{before.bitrate}` โ†’ `{after.bitrate}`" ) if ( hasattr(before, "user_limit") and hasattr(after, "user_limit") and before.user_limit != after.user_limit ): detailed_changes.append( f"User Limit: `{before.user_limit}` โ†’ `{after.user_limit}`" ) if ( hasattr(before, "position") and hasattr(after, "position") and before.position != after.position ): detailed_changes.append( f"Position: `{before.position}` โ†’ `{after.position}`" ) # Add more attribute comparisons as needed except Exception as e: log.error(f"Error processing audit log changes: {e}", exc_info=True) detailed_changes.append(f"Error processing changes: {e}") if detailed_changes: action_desc = ( f"{user.mention} updated {ch_type} channel {channel.mention} ({channel.id}):\n" + "\n".join(f"- {c}" for c in detailed_changes) ) color = discord.Color.yellow() # self._add_id_footer(embed, channel, id_name="Channel ID") # Footer set later else: # log.debug(f"Channel update detected for {channel.id} but no tracked changes found.") # Might still want to log permission changes return # Skip if no changes we track were made # --- Message Events (Delete, Bulk Delete) --- elif entry.action == discord.AuditLogAction.message_delete: audit_event_key = "audit_message_delete" if not await self._check_log_enabled(guild.id, audit_event_key): return title = "๐Ÿ›ก๏ธ Audit Log: Message Deleted" # Title adjusted for clarity channel = entry.extra.channel count = entry.extra.count action_desc = f"{user.mention} deleted {count} message(s) by {self._user_display(target)} in {channel.mention}" color = discord.Color.dark_grey() elif entry.action == discord.AuditLogAction.message_bulk_delete: audit_event_key = "audit_message_bulk_delete" if not await self._check_log_enabled(guild.id, audit_event_key): return title = "๐Ÿ›ก๏ธ Audit Log: Message Bulk Delete" channel_target = entry.target # Channel is the target here count = entry.extra.count channel_display = "" if hasattr(channel_target, "mention"): channel_display = channel_target.mention elif isinstance(channel_target, discord.Object) and hasattr( channel_target, "id" ): # If it's an Object, it might be a deleted channel or not fully loaded. # Using <#id> is a safe way to reference it. channel_display = f"<#{channel_target.id}>" else: # Fallback if it's not an object with 'mention' or an 'Object' with 'id' channel_display = ( f"an unknown channel (ID: {getattr(channel_target, 'id', 'N/A')})" ) action_desc = ( f"{user.mention} bulk deleted {count} messages in {channel_display}" ) color = discord.Color.dark_grey() # self._add_id_footer(embed, channel_target, id_name="Channel ID") # Footer set later # --- Emoji Events --- elif entry.action == discord.AuditLogAction.emoji_create: audit_event_key = "audit_emoji_create" if not await self._check_log_enabled(guild.id, audit_event_key): return title = "๐Ÿ›ก๏ธ Audit Log: Emoji Created" emoji = target emoji_name = getattr(emoji, "name", None) if emoji_name: action_desc = f"{user.mention} created emoji {emoji} (`{emoji_name}`)" else: emoji_id = getattr(emoji, "id", "unknown") action_desc = f"{user.mention} created emoji with ID `{emoji_id}`" color = discord.Color.magenta() # self._add_id_footer(embed, emoji, id_name="Emoji ID") # Footer set later elif entry.action == discord.AuditLogAction.emoji_delete: audit_event_key = "audit_emoji_delete" if not await self._check_log_enabled(guild.id, audit_event_key): return title = "๐Ÿ›ก๏ธ Audit Log: Emoji Deleted" emoji_name = getattr(entry.before, "name", None) emoji_id = getattr(entry.target, "id", "unknown") if emoji_name: action_desc = ( f"{user.mention} deleted emoji `{emoji_name}` ({emoji_id})" ) else: action_desc = f"{user.mention} deleted an emoji (ID: {emoji_id})" color = discord.Color.dark_magenta() # self._add_id_footer(embed, obj_id=emoji_id, id_name="Emoji ID") # Footer set later elif entry.action == discord.AuditLogAction.emoji_update: audit_event_key = "audit_emoji_update" if not await self._check_log_enabled(guild.id, audit_event_key): return title = "๐Ÿ›ก๏ธ Audit Log: Emoji Updated" emoji = target if ( hasattr(entry.before, "name") and hasattr(entry.after, "name") and entry.before.name != entry.after.name ): emoji_name_after = getattr(emoji, "name", None) if emoji_name_after: action_desc = f"{user.mention} renamed emoji `{entry.before.name}` to {emoji} (`{emoji_name_after}`)" else: emoji_id = getattr(emoji, "id", "unknown") action_desc = f"{user.mention} renamed emoji `{entry.before.name}` (ID: {emoji_id})" color = discord.Color.magenta() # self._add_id_footer(embed, emoji, id_name="Emoji ID") # Footer set later else: # log.debug(f"Emoji update detected for {emoji.id} but no tracked changes found.") # Only log name changes for now return # Only log name changes for now # --- Invite Events --- elif entry.action == discord.AuditLogAction.invite_create: audit_event_key = "audit_invite_create" if not await self._check_log_enabled(guild.id, audit_event_key): return title = "๐Ÿ›ก๏ธ Audit Log: Invite Created" invite = target # Target is the invite object channel = invite.channel desc = f"Invite `{invite.code}` created for {channel.mention if channel else 'Unknown Channel'}" if invite.max_age: # Use invite.created_at if available, otherwise fall back to current time created_time = ( invite.created_at if invite.created_at is not None else discord.utils.utcnow() ) expires_at = created_time + datetime.timedelta(seconds=invite.max_age) desc += f"\nExpires: {discord.utils.format_dt(expires_at, style='R')}" if invite.max_uses: desc += f"\nMax Uses: {invite.max_uses}" action_desc = f"{user.mention} created an invite:\n{desc}" color = discord.Color.dark_green() # self._add_id_footer(embed, invite, obj_id=invite.id, id_name="Invite ID") # Footer set later elif entry.action == discord.AuditLogAction.invite_delete: audit_event_key = "audit_invite_delete" if not await self._check_log_enabled(guild.id, audit_event_key): return title = "๐Ÿ›ก๏ธ Audit Log: Invite Deleted" # Target is invite ID, before object has details invite_code = entry.before.code channel_id = entry.before.channel_id channel_mention = f"<#{channel_id}>" if channel_id else "Unknown Channel" action_desc = f"{user.mention} deleted invite `{invite_code}` for channel {channel_mention}" color = discord.Color.dark_red() # Cannot get invite ID after deletion easily, use code in footer later # --- Guild Update --- elif entry.action == discord.AuditLogAction.guild_update: audit_event_key = "audit_guild_update" if not await self._check_log_enabled(guild.id, audit_event_key): return title = "๐Ÿ›ก๏ธ Audit Log: Guild Updated" changes = [] # Diffing guild properties - safely check attributes exist before comparing if ( hasattr(entry.before, "name") and hasattr(entry.after, "name") and entry.before.name != entry.after.name ): changes.append(f"Name: `{entry.before.name}` โ†’ `{entry.after.name}`") if ( hasattr(entry.before, "description") and hasattr(entry.after, "description") and entry.before.description != entry.after.description ): changes.append(f"Description Changed") if ( hasattr(entry.before, "icon") and hasattr(entry.after, "icon") and entry.before.icon != entry.after.icon ): changes.append(f"Icon Changed") if ( hasattr(entry.before, "banner") and hasattr(entry.after, "banner") and entry.before.banner != entry.after.banner ): changes.append(f"Banner Changed") if ( hasattr(entry.before, "owner") and hasattr(entry.after, "owner") and entry.before.owner != entry.after.owner ): changes.append( f"Owner: {entry.before.owner.mention if entry.before.owner else 'None'} โ†’ {entry.after.owner.mention if entry.after.owner else 'None'}" ) if ( hasattr(entry.before, "verification_level") and hasattr(entry.after, "verification_level") and entry.before.verification_level != entry.after.verification_level ): changes.append( f"Verification Level: `{entry.before.verification_level}` โ†’ `{entry.after.verification_level}`" ) if ( hasattr(entry.before, "explicit_content_filter") and hasattr(entry.after, "explicit_content_filter") and entry.before.explicit_content_filter != entry.after.explicit_content_filter ): changes.append( f"Explicit Content Filter: `{entry.before.explicit_content_filter}` โ†’ `{entry.after.explicit_content_filter}`" ) if ( hasattr(entry.before, "system_channel") and hasattr(entry.after, "system_channel") and entry.before.system_channel != entry.after.system_channel ): changes.append(f"System Channel Changed") # Add more properties as needed if changes: action_desc = f"{user.mention} updated server settings:\n" + "\n".join( f"- {c}" for c in changes ) color = discord.Color.dark_purple() # self._add_id_footer(embed, guild, id_name="Guild ID") # Footer set later else: # log.debug(f"Guild update detected for {guild.id} but no tracked changes found.") # Might still want to log feature changes etc. return # Skip if no changes we track were made else: # Action is in relevant_actions but not specifically handled above log.warning( f"Audit log action '{entry.action}' is relevant but not explicitly handled in _process_audit_log_entry." ) # Generic fallback log title = f"๐Ÿ›ก๏ธ Audit Log: {str(entry.action).replace('_', ' ').title()}" # Determine the generic audit key based on the action category if possible generic_audit_key = f"audit_{str(entry.action).split('.')[0]}" # e.g., audit_member, audit_channel if generic_audit_key in ALL_EVENT_KEYS: if not await self._check_log_enabled(guild.id, generic_audit_key): return else: log.warning( f"No specific or generic toggle key found for unhandled audit action '{entry.action}'. Logging anyway." ) # Or decide to return here if you only want explicitly toggled events logged title = f"๐Ÿ›ก๏ธ Audit Log: {str(entry.action).replace('_', ' ').title()}" action_desc = f"{user.mention} performed action `{entry.action}`" if target: target_mention = getattr(target, "mention", str(target)) action_desc += f" on {target_mention}" # self._add_id_footer(embed, target, id_name="Target ID") # Footer set later color = discord.Color.light_grey() if ( not action_desc ): # If no description was generated (e.g., skipped update), skip logging # log.debug(f"Skipping audit log entry {entry.id} (action: {entry.action}) as no action description was generated.") return # Create the embed (title is set within the if/elif blocks now) embed = self._create_log_embed( title=title, description=action_desc.strip(), color=color, author=user, # The moderator/actor is the author of the log entry ) if reason: embed.add_field( name="Reason", value=reason[:1024], inline=False ) # Limit reason length # Add relevant IDs to footer (target ID if available, otherwise just mod/entry ID) target_id_str = "" if target: target_id_str = f" | Target ID: {target.id}" elif entry.action == discord.AuditLogAction.role_delete: target_id_str = f" | Role ID: {entry.target.id}" # Get ID from target even if object deleted elif entry.action == discord.AuditLogAction.channel_delete: target_id_str = f" | Channel ID: {entry.target.id}" elif entry.action == discord.AuditLogAction.emoji_delete: target_id_str = f" | Emoji ID: {entry.target.id}" elif entry.action == discord.AuditLogAction.invite_delete: target_id_str = ( f" | Invite Code: {entry.before.code}" # Use code for deleted invites ) embed.set_footer( f"Audit Log Entry ID: {entry.id} | Moderator ID: {user.id}{target_id_str}" ) await self._send_log_embed(guild, embed) async def setup(bot: commands.Bot): await bot.add_cog(LoggingCog(bot)) log.info("LoggingCog added.")