discordbot/cogs/logging_cog.py

2434 lines
109 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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=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)}:"
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
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(
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.")