import discord from discord.ext import commands, tasks from discord import ui, AllowedMentions import datetime 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 # 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): """Simple view for log messages with helper methods.""" def __init__( self, bot: commands.Bot, title: str, description: str, color: discord.Color, author: Optional[discord.abc.User], footer: Optional[str], ): super().__init__(timeout=None) self.container = ui.Container(accent_colour=color) self.add_item(self.container) self.header = ui.Section( accessory=( ui.Thumbnail(media=author.display_avatar.url) if author else ui.Button(label="\u200b", disabled=True) ) ) self.header.add_item(ui.TextDisplay(f"**{title}**")) if description: self.header.add_item(ui.TextDisplay(description)) self.container.add_item(self.header) # Placeholder for future field sections. They are inserted before # the separator when the first field is added. self._field_sections: list[ui.Section] = [] self.separator = ui.Separator(spacing=discord.SeparatorSpacing.small) footer_text = footer or f"Bot ID: {bot.user.id}" + ( f" | User ID: {author.id}" if author else "" ) self.footer_display = ui.TextDisplay(footer_text) self.container.add_item(self.separator) self.container.add_item(self.footer_display) # --- Compatibility helpers --- def add_field(self, name: str, value: str, inline: bool = False): """Mimic Embed.add_field by appending a bolded name/value line.""" if not self._field_sections or len(self._field_sections[-1].children) >= 3: section = ui.Section(accessory=ui.Button(label="\u200b", disabled=True)) self._insert_field_section(section) self._field_sections.append(section) self._field_sections[-1].add_item(ui.TextDisplay(f"**{name}:** {value}")) def _insert_field_section(self, section: ui.Section) -> None: """Insert a field section before the footer separator.""" self.container.remove_item(self.separator) self.container.remove_item(self.footer_display) self.container.add_item(section) self.container.add_item(self.separator) self.container.add_item(self.footer_display) def set_footer(self, text: str): """Mimic Embed.set_footer by replacing the footer text display.""" self.footer_display.content = text def set_author(self, name: str, icon_url: Optional[str] = None): """Mimic Embed.set_author by adjusting the header section.""" self.header.clear_items() if icon_url: self.header.accessory = ui.Thumbnail(media=icon_url) else: self.header.accessory = ui.Button(label="\u200b", disabled=True) self.header.add_item(ui.TextDisplay(name)) 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"Invalid logging webhook URL configured 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 and hasattr(embed, "footer_display"): existing_footer = embed.footer_display.content or "" separator = " | " if existing_footer else "" embed.footer_display.content = f"{existing_footer}{separator}{id_name}: {target_id}" 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 # --- 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( view=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) embed = discord.Embed(title=f"Logging Status for {ctx.guild.name}", color=discord.Color.blue()) lines = [] for key in ALL_EVENT_KEYS: # Get status, defaulting to True if not explicitly in the DB/cache map is_enabled = toggles.get(key, True) status_emoji = "✅" if is_enabled else "❌" lines.append(f"{status_emoji} `{key}`") # Paginate if too long for one embed description description = "" for line in lines: if len(description) + len(line) + 1 > 4000: # Embed description limit (approx) embed.description = description await ctx.send(embed=embed, allowed_mentions=AllowedMentions.none()) description = line + "\n" # Start new description embed = discord.Embed(color=discord.Color.blue()) # New embed for continuation else: description += line + "\n" if description: # Send the last embed page embed.description = description.strip() await ctx.send(embed=embed, 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.""" embed = discord.Embed(title="Available Logging Event Keys", color=discord.Color.purple()) keys_text = "\n".join(f"`{key}`" for key in ALL_EVENT_KEYS) # Paginate if needed 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) embed.description = parts[0] await ctx.send(embed=embed, allowed_mentions=AllowedMentions.none()) for part in parts[1:]: await ctx.send( embed=discord.Embed(description=part, color=discord.Color.purple()), allowed_mentions=AllowedMentions.none(), ) else: embed.description = keys_text await ctx.send(embed=embed, 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(), # Creator might be available via thread.owner_id or audit log footer=f"Thread ID: {thread.id} | Parent ID: {thread.parent_id}" ) if thread.owner: # Sometimes owner isn't cached immediately embed.set_author(name=str(thread.owner), icon_url=thread.owner.display_avatar.url) 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: 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: 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 embed = self._create_log_embed( title="✏️ Message Edited", description=f"Message edited in {after.channel.mention} [Jump to Message]({after.jump_url})", color=discord.Color.light_grey(), author=after.author ) # Add fields for before and after, handling potential length limits embed.add_field(name="Before", value=before.content[:1020] + ('...' if len(before.content) > 1020 else '') or "`Empty Message`", inline=False) embed.add_field(name="After", value=after.content[:1020] + ('...' if len(after.content) > 1020 else '') or "`Empty Message`", 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 desc = f"Message deleted in {message.channel.mention}" # Audit log needed for *who* deleted it, if not the author themselves # We can add a placeholder here and update it if the audit log confirms a moderator deletion later embed = self._create_log_embed( title="🗑️ Message Deleted", description=f"{desc}\n*Audit log may contain deleter if not the author.*", color=discord.Color.dark_grey(), author=message.author ) 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(text=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=30) # Poll every 30 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)} ({target.id}):" 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)} ({target.id}) until {timeout_duration}" color = discord.Color.orange() else: action_desc = f"{self._user_display(user)} removed timeout from {self._user_display(target)} ({target.id})" 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 {target.mention} ({target.id}) 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 action_desc = f"{user.mention} created emoji {emoji} (`{emoji.name}`)" 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 = entry.before.name emoji_id = entry.target.id action_desc = f"{user.mention} deleted emoji `{emoji_name}` ({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: action_desc = f"{user.mention} renamed emoji `{entry.before.name}` to {emoji} (`{emoji.name}`)" 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(text=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.")