2374 lines
106 KiB
Python
2374 lines
106 KiB
Python
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)
|
||
)
|
||
|
||
# Place main log content between separators
|
||
self.content_container = ui.Container()
|
||
self.container.add_item(self.content_container)
|
||
self.container.add_item(
|
||
ui.Separator(spacing=discord.SeparatorSpacing.small)
|
||
)
|
||
|
||
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:
|
||
self.content_container.add_item(ui.TextDisplay(f"**{name}:** {value}"))
|
||
|
||
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
|
||
|
||
# --- 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:
|
||
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
|
||
|
||
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,
|
||
)
|
||
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)} ({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(
|
||
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.")
|