Merge work

This commit is contained in:
Slipstream 2025-06-05 17:46:35 +00:00
commit 934b4f9ca1
Signed by: slipstream
GPG Key ID: 13E498CE010AC6FD
4 changed files with 186 additions and 149 deletions

View File

@ -1,5 +1,6 @@
import discord import discord
from discord.ext import commands, tasks from discord.ext import commands, tasks
from discord import ui
import datetime import datetime
import asyncio import asyncio
import aiohttp # Added for webhook sending import aiohttp # Added for webhook sending
@ -54,6 +55,36 @@ class LoggingCog(commands.Cog):
else: else:
asyncio.create_task(self.start_audit_log_poller_when_ready()) # Keep this for initial start asyncio.create_task(self.start_audit_log_poller_when_ready()) # Keep this for initial start
class LogView(ui.LayoutView):
"""Simple view for log messages."""
def __init__(self, bot: commands.Bot, title: str, description: str,
color: discord.Color, author: Optional[discord.abc.User],
footer: Optional[str]):
super().__init__(timeout=None)
container = ui.Container(accent_colour=color)
self.add_item(container)
header = ui.Section(accessory=(ui.Thumbnail(media=author.display_avatar.url)
if author else None))
header.add_item(ui.TextDisplay(f"**{title}**"))
if description:
header.add_item(ui.TextDisplay(description))
container.add_item(header)
container.add_item(ui.Separator(spacing=discord.SeparatorSpacing.small))
footer_text = footer or f"Bot ID: {bot.user.id}" + (
f" | User ID: {author.id}" if author else "")
self.footer_display = ui.TextDisplay(footer_text)
container.add_item(self.footer_display)
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): async def initialize_cog(self):
"""Asynchronous initialization tasks.""" """Asynchronous initialization tasks."""
log.info("Initializing LoggingCog...") log.info("Initializing LoggingCog...")
@ -103,8 +134,8 @@ class LoggingCog(commands.Cog):
await self.session.close() await self.session.close()
log.info("aiohttp ClientSession closed for LoggingCog.") log.info("aiohttp ClientSession closed for LoggingCog.")
async def _send_log_embed(self, guild: discord.Guild, embed: discord.Embed): async def _send_log_embed(self, guild: discord.Guild, embed: ui.LayoutView):
"""Sends the log embed via the configured webhook for the guild.""" """Sends the log view via the configured webhook for the guild."""
if not self.session or self.session.closed: 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.") log.error(f"aiohttp session not available or closed in LoggingCog for guild {guild.id}. Cannot send log.")
return return
@ -118,9 +149,9 @@ class LoggingCog(commands.Cog):
try: try:
webhook = discord.Webhook.from_url(webhook_url, session=self.session) webhook = discord.Webhook.from_url(webhook_url, session=self.session)
await webhook.send( await webhook.send(
embed=embed, view=embed,
username=f"{self.bot.user.name} Logs", # Optional: Customize webhook appearance username=f"{self.bot.user.name} Logs",
avatar_url=self.bot.user.display_avatar.url # Optional: Use bot's avatar avatar_url=self.bot.user.display_avatar.url,
) )
# log.debug(f"Sent log embed via webhook for guild {guild.id}") # Can be noisy # log.debug(f"Sent log embed via webhook for guild {guild.id}") # Can be noisy
except ValueError: except ValueError:
@ -139,26 +170,17 @@ class LoggingCog(commands.Cog):
log.exception(f"Unexpected error sending log via webhook for guild {guild.id}: {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) -> discord.Embed: 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 embed.""" """Creates a standardized log view."""
embed = discord.Embed(title=title, description=description, color=color, timestamp=datetime.datetime.now(datetime.timezone.utc)) return self.LogView(self.bot, title, description, color, author, footer)
if author:
embed.set_author(name=str(author), icon_url=author.display_avatar.url)
if footer:
embed.set_footer(text=footer)
else:
# Add User ID to footer if author is present and footer isn't custom
user_id_str = f" | User ID: {author.id}" if author else ""
embed.set_footer(text=f"Bot ID: {self.bot.user.id}{user_id_str}")
return embed
def _add_id_footer(self, embed: discord.Embed, 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"): 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"):
"""Adds an ID to the embed footer if possible.""" """Adds an ID to the footer text if possible."""
target_id = obj_id or (obj.id if obj else None) target_id = obj_id or (obj.id if obj else None)
if target_id: if target_id and hasattr(embed, "footer_display"):
existing_footer = embed.footer.text or "" existing_footer = embed.footer_display.content or ""
separator = " | " if existing_footer else "" separator = " | " if existing_footer else ""
embed.set_footer(text=f"{existing_footer}{separator}{id_name}: {target_id}") embed.footer_display.content = f"{existing_footer}{separator}{id_name}: {target_id}"
async def _check_log_enabled(self, guild_id: int, event_key: str) -> bool: 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.""" """Checks if logging is enabled for a specific event key in a guild."""
@ -418,7 +440,7 @@ class LoggingCog(commands.Cog):
user = await self.bot.fetch_user(member.id) # Get user object user = await self.bot.fetch_user(member.id) # Get user object
embed = self._create_log_embed( embed = self._create_log_embed(
title=" Member Joined Thread", title=" Member Joined Thread",
description=f"{user.mention} joined thread {thread.mention}.", description=f"{self._user_display(user)} joined thread {thread.mention}.",
color=discord.Color.dark_green(), color=discord.Color.dark_green(),
author=user, author=user,
footer=f"Thread ID: {thread.id} | User ID: {user.id}" footer=f"Thread ID: {thread.id} | User ID: {user.id}"
@ -435,7 +457,7 @@ class LoggingCog(commands.Cog):
user = await self.bot.fetch_user(member.id) # Get user object user = await self.bot.fetch_user(member.id) # Get user object
embed = self._create_log_embed( embed = self._create_log_embed(
title=" Member Left Thread", title=" Member Left Thread",
description=f"{user.mention} left thread {thread.mention}.", description=f"{self._user_display(user)} left thread {thread.mention}.",
color=discord.Color.dark_orange(), color=discord.Color.dark_orange(),
author=user, author=user,
footer=f"Thread ID: {thread.id} | User ID: {user.id}" footer=f"Thread ID: {thread.id} | User ID: {user.id}"
@ -509,7 +531,7 @@ class LoggingCog(commands.Cog):
embed = self._create_log_embed( embed = self._create_log_embed(
title="📥 Member Joined", title="📥 Member Joined",
description=f"{member.mention} ({member.id}) joined the server.", description=f"{self._user_display(member)} joined the server.",
color=discord.Color.green(), color=discord.Color.green(),
author=member author=member
# Footer already includes User ID via _create_log_embed # Footer already includes User ID via _create_log_embed
@ -527,7 +549,7 @@ class LoggingCog(commands.Cog):
# We log it as a generic "left" event here. # We log it as a generic "left" event here.
embed = self._create_log_embed( embed = self._create_log_embed(
title="📤 Member Left", title="📤 Member Left",
description=f"{member.mention} left the server.", description=f"{self._user_display(member)} left the server.",
color=discord.Color.orange(), color=discord.Color.orange(),
author=member author=member
) )
@ -542,7 +564,7 @@ class LoggingCog(commands.Cog):
# Note: Ban reason isn't available directly in this event. Audit log might have it. # Note: Ban reason isn't available directly in this event. Audit log might have it.
embed = self._create_log_embed( embed = self._create_log_embed(
title="🔨 Member Banned (Event)", # Clarify this is the event, audit log has more details title="🔨 Member Banned (Event)", # Clarify this is the event, audit log has more details
description=f"{user.mention} was banned.\n*Audit log may contain moderator and reason.*", description=f"{self._user_display(user)} was banned.\n*Audit log may contain moderator and reason.*",
color=discord.Color.red(), color=discord.Color.red(),
author=user # User who was banned author=user # User who was banned
) )
@ -556,7 +578,7 @@ class LoggingCog(commands.Cog):
embed = self._create_log_embed( embed = self._create_log_embed(
title="🔓 Member Unbanned", title="🔓 Member Unbanned",
description=f"{user.mention} was unbanned.", description=f"{self._user_display(user)} was unbanned.",
color=discord.Color.blurple(), color=discord.Color.blurple(),
author=user # User who was unbanned author=user # User who was unbanned
) )
@ -841,7 +863,7 @@ class LoggingCog(commands.Cog):
embed = self._create_log_embed( embed = self._create_log_embed(
title="👍 Reaction Added", title="👍 Reaction Added",
description=f"{user.mention} added {reaction.emoji} to a message by {reaction.message.author.mention} in {reaction.message.channel.mention} [Jump to Message]({reaction.message.jump_url})", 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(), color=discord.Color.gold(),
author=user author=user
) )
@ -860,7 +882,7 @@ class LoggingCog(commands.Cog):
embed = self._create_log_embed( embed = self._create_log_embed(
title="👎 Reaction Removed", title="👎 Reaction Removed",
description=f"{user.mention} removed {reaction.emoji} from a message by {reaction.message.author.mention} in {reaction.message.channel.mention} [Jump to Message]({reaction.message.jump_url})", 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(), color=discord.Color.dark_gold(),
author=user author=user
) )
@ -959,7 +981,7 @@ class LoggingCog(commands.Cog):
embed = self._create_log_embed( embed = self._create_log_embed(
title=action, title=action,
description=f"{member.mention}\n{details}", description=f"{self._user_display(member)}\n{details}",
color=color, color=color,
author=member author=member
) )
@ -1264,21 +1286,21 @@ class LoggingCog(commands.Cog):
audit_event_key = "audit_ban" audit_event_key = "audit_ban"
if not await self._check_log_enabled(guild.id, audit_event_key): return if not await self._check_log_enabled(guild.id, audit_event_key): return
title = "🛡️ Audit Log: Member Banned" title = "🛡️ Audit Log: Member Banned"
action_desc = f"{user.mention} banned {target.mention}" action_desc = f"{self._user_display(user)} banned {self._user_display(target)}"
color = discord.Color.red() color = discord.Color.red()
# self._add_id_footer(embed, target, id_name="Target ID") # Footer set later # self._add_id_footer(embed, target, id_name="Target ID") # Footer set later
elif entry.action == discord.AuditLogAction.unban: elif entry.action == discord.AuditLogAction.unban:
audit_event_key = "audit_unban" audit_event_key = "audit_unban"
if not await self._check_log_enabled(guild.id, audit_event_key): return if not await self._check_log_enabled(guild.id, audit_event_key): return
title = "🛡️ Audit Log: Member Unbanned" title = "🛡️ Audit Log: Member Unbanned"
action_desc = f"{user.mention} unbanned {target.mention}" action_desc = f"{self._user_display(user)} unbanned {self._user_display(target)}"
color = discord.Color.blurple() color = discord.Color.blurple()
# self._add_id_footer(embed, target, id_name="Target ID") # Footer set later # self._add_id_footer(embed, target, id_name="Target ID") # Footer set later
elif entry.action == discord.AuditLogAction.kick: elif entry.action == discord.AuditLogAction.kick:
audit_event_key = "audit_kick" audit_event_key = "audit_kick"
if not await self._check_log_enabled(guild.id, audit_event_key): return if not await self._check_log_enabled(guild.id, audit_event_key): return
title = "🛡️ Audit Log: Member Kicked" title = "🛡️ Audit Log: Member Kicked"
action_desc = f"{user.mention} kicked {target.mention}" action_desc = f"{self._user_display(user)} kicked {self._user_display(target)}"
color = discord.Color.brand_red() color = discord.Color.brand_red()
# self._add_id_footer(embed, target, id_name="Target ID") # Footer set later # self._add_id_footer(embed, target, id_name="Target ID") # Footer set later
elif entry.action == discord.AuditLogAction.member_prune: elif entry.action == discord.AuditLogAction.member_prune:
@ -1287,7 +1309,7 @@ class LoggingCog(commands.Cog):
title = "🛡️ Audit Log: Member Prune" title = "🛡️ Audit Log: Member Prune"
days = entry.extra.get('delete_member_days') days = entry.extra.get('delete_member_days')
count = entry.extra.get('members_removed') count = entry.extra.get('members_removed')
action_desc = f"{user.mention} pruned {count} members inactive for {days} days." action_desc = f"{self._user_display(user)} pruned {count} members inactive for {days} days."
color = discord.Color.dark_red() color = discord.Color.dark_red()
# No specific target ID here # No specific target ID here
@ -1301,7 +1323,7 @@ class LoggingCog(commands.Cog):
added = [r.mention for r in after_roles if r not in before_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] removed = [r.mention for r in before_roles if r not in after_roles]
if added or removed: # Only log if roles actually changed if added or removed: # Only log if roles actually changed
action_desc = f"{user.mention} updated roles for {target.mention} ({target.id}):" 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 added: action_desc += f"\n**Added:** {', '.join(added)}"
if removed: action_desc += f"\n**Removed:** {', '.join(removed)}" if removed: action_desc += f"\n**Removed:** {', '.join(removed)}"
color = discord.Color.blue() color = discord.Color.blue()
@ -1317,10 +1339,10 @@ class LoggingCog(commands.Cog):
title = "🛡️ Audit Log: Member Timeout Update" title = "🛡️ Audit Log: Member Timeout Update"
if after_timed_out: if after_timed_out:
timeout_duration = discord.utils.format_dt(after_timed_out, style='R') timeout_duration = discord.utils.format_dt(after_timed_out, style='R')
action_desc = f"{user.mention} timed out {target.mention} ({target.id}) until {timeout_duration}" action_desc = f"{self._user_display(user)} timed out {self._user_display(target)} ({target.id}) until {timeout_duration}"
color = discord.Color.orange() color = discord.Color.orange()
else: else:
action_desc = f"{user.mention} removed timeout from {target.mention} ({target.id})" action_desc = f"{self._user_display(user)} removed timeout from {self._user_display(target)} ({target.id})"
color = discord.Color.green() color = discord.Color.green()
# self._add_id_footer(embed, target, id_name="Target ID") # Footer set later # self._add_id_footer(embed, target, id_name="Target ID") # Footer set later
else: else:

View File

@ -1,6 +1,6 @@
import discord import discord
from discord.ext import commands from discord.ext import commands
from discord import app_commands, Interaction, Embed, Color, User, Member, Object from discord import app_commands, Interaction, Embed, Color, User, Member, Object, ui
import asyncpg import asyncpg
import logging import logging
from typing import Optional, Union, Dict, Any from typing import Optional, Union, Dict, Any
@ -32,6 +32,49 @@ class ModLogCog(commands.Cog):
# Add command group to the bot's tree # Add command group to the bot's tree
self.bot.tree.add_command(self.modlog_group) self.bot.tree.add_command(self.modlog_group)
class LogView(ui.LayoutView):
"""View used for moderation log messages."""
def __init__(self, bot: commands.Bot, title: str, color: discord.Color, lines: list[str], footer: str):
super().__init__(timeout=None)
container = ui.Container(accent_colour=color)
self.add_item(container)
container.add_item(ui.TextDisplay(f"**{title}**"))
container.add_item(ui.Separator(spacing=discord.SeparatorSpacing.small))
for line in lines:
container.add_item(ui.TextDisplay(line))
container.add_item(ui.Separator(spacing=discord.SeparatorSpacing.small))
self.footer_display = ui.TextDisplay(footer)
container.add_item(self.footer_display)
def _format_user(self, user: Union[Member, User, Object], guild: Optional[discord.Guild] = None) -> str:
"""Return a string with display name, username and ID for a user-like object."""
if isinstance(user, Object):
return f"Unknown User (ID: {user.id})"
if isinstance(user, Member):
display = user.display_name
elif guild and isinstance(user, User):
member = guild.get_member(user.id)
display = member.display_name if member else user.name
else:
display = user.name
username = f"{user.name}#{user.discriminator}" if isinstance(user, (Member, User)) else "Unknown"
return f"{display} ({username}) [ID: {user.id}]"
async def _fetch_user_display(self, user_id: int, guild: discord.Guild) -> str:
"""Fetch and format a user by ID for display."""
member = guild.get_member(user_id)
if member:
return self._format_user(member, guild)
user = self.bot.get_user(user_id)
if user:
return self._format_user(user, guild)
try:
user = await self.bot.fetch_user(user_id)
return self._format_user(user, guild)
except discord.HTTPException:
return f"Unknown User (ID: {user_id})"
def register_commands(self): def register_commands(self):
"""Register all commands for this cog""" """Register all commands for this cog"""
@ -192,8 +235,8 @@ class ModLogCog(commands.Cog):
# Optionally update DB to remove channel ID? Or just leave it. # Optionally update DB to remove channel ID? Or just leave it.
return return
# 3. Format and send embed # 3. Format and send view
embed = self._format_log_embed( view = self._format_log_embed(
case_id=case_id, case_id=case_id,
moderator=moderator, # Pass the object for display formatting moderator=moderator, # Pass the object for display formatting
target=target, target=target,
@ -205,7 +248,7 @@ class ModLogCog(commands.Cog):
ai_details=ai_details, ai_details=ai_details,
moderator_id_override=moderator_id_override # Pass override for formatting moderator_id_override=moderator_id_override # Pass override for formatting
) )
log_message = await log_channel.send(embed=embed) log_message = await log_channel.send(view=view)
# 4. Update DB with message details # 4. Update DB with message details
await mod_log_db.update_mod_log_message_details(self.pool, case_id, log_message.id, log_channel.id) await mod_log_db.update_mod_log_message_details(self.pool, case_id, log_message.id, log_channel.id)
@ -213,6 +256,7 @@ class ModLogCog(commands.Cog):
except Exception as e: except Exception as e:
log.exception(f"Error during Discord mod log message sending/updating for case {case_id} in guild {guild_id}: {e}") log.exception(f"Error during Discord mod log message sending/updating for case {case_id} in guild {guild_id}: {e}")
def _format_log_embed( def _format_log_embed(
self, self,
case_id: int, case_id: int,
@ -224,9 +268,9 @@ class ModLogCog(commands.Cog):
guild: discord.Guild, guild: discord.Guild,
source: str = "BOT", source: str = "BOT",
ai_details: Optional[Dict[str, Any]] = None, ai_details: Optional[Dict[str, Any]] = None,
moderator_id_override: Optional[int] = None moderator_id_override: Optional[int] = None,
) -> Embed: ) -> ui.LayoutView:
"""Helper function to create the standard log embed.""" """Helper function to create the standard log view."""
color_map = { color_map = {
"BAN": Color.red(), "BAN": Color.red(),
"UNBAN": Color.green(), "UNBAN": Color.green(),
@ -237,98 +281,56 @@ class ModLogCog(commands.Cog):
"AI_ALERT": Color.purple(), "AI_ALERT": Color.purple(),
"AI_DELETE_REQUESTED": Color.dark_grey(), "AI_DELETE_REQUESTED": Color.dark_grey(),
} }
# Use a distinct color for AI actions embed_color = Color.blurple() if source == "AI_API" else color_map.get(action_type.upper(), Color.greyple())
if source == "AI_API":
embed_color = Color.blurple()
else:
embed_color = color_map.get(action_type.upper(), Color.greyple())
action_title_prefix = "🤖 AI Moderation Action" if source == "AI_API" else action_type.replace("_", " ").title() action_title_prefix = "🤖 AI Moderation Action" if source == "AI_API" else action_type.replace("_", " ").title()
action_title = f"{action_title_prefix} | Case #{case_id}" action_title = f"{action_title_prefix} | Case #{case_id}"
target_display = self._format_user(target, guild)
embed = Embed( moderator_display = (
title=action_title, f"AI System (ID: {moderator_id_override or 'Unknown'})" if source == "AI_API" else self._format_user(moderator, guild)
color=embed_color,
timestamp=discord.utils.utcnow()
) )
lines = [f"**User:** {target_display}", f"**Moderator:** {moderator_display}"]
# Handle target display - check if it's a Discord Object or User/Member
if isinstance(target, discord.Object):
# For Object, we only have the ID
target_display = f"<@{target.id}> ({target.id})"
else:
# For User/Member, we can use mention
target_display = f"{target.mention} ({target.id})"
# Determine moderator display based on source
if source == "AI_API":
moderator_display = f"AI System (ID: {moderator_id_override or 'Unknown'})"
elif isinstance(moderator, discord.Object):
# For Object, we only have the ID
moderator_display = f"<@{moderator.id}> ({moderator.id})"
else:
# For User/Member, we can use mention
moderator_display = f"{moderator.mention} ({moderator.id})"
embed.add_field(name="User", value=target_display, inline=True)
embed.add_field(name="Moderator", value=moderator_display, inline=True)
# Add AI-specific details if available
if ai_details: if ai_details:
if 'rule_violated' in ai_details: if "rule_violated" in ai_details:
embed.add_field(name="Rule Violated", value=ai_details['rule_violated'], inline=True) lines.append(f"**Rule Violated:** {ai_details['rule_violated']}")
if 'reasoning' in ai_details: if "reasoning" in ai_details:
# Use AI reasoning as the main reason field if bot reason is empty reason_to_display = reason or ai_details["reasoning"]
reason_to_display = reason or ai_details['reasoning'] lines.append(f"**Reason / AI Reasoning:** {reason_to_display or 'No reason provided.'}")
embed.add_field(name="Reason / AI Reasoning", value=reason_to_display or "No reason provided.", inline=False) if reason and reason != ai_details["reasoning"]:
# Optionally add bot reason separately if both exist and differ lines.append(f"**Original Bot Reason:** {reason}")
if reason and reason != ai_details['reasoning']:
embed.add_field(name="Original Bot Reason", value=reason, inline=False)
else: else:
embed.add_field(name="Reason", value=reason or "No reason provided.", inline=False) lines.append(f"**Reason:** {reason or 'No reason provided.'}")
if "message_content" in ai_details:
# Add full message content if available message_content = ai_details["message_content"]
if 'message_content' in ai_details:
# Truncate if too long (Discord has a 1024 character limit for embed fields)
message_content = ai_details['message_content']
if len(message_content) > 1000: if len(message_content) > 1000:
message_content = message_content[:997] + "..." message_content = message_content[:997] + "..."
embed.add_field(name="Message Content", value=message_content, inline=False) lines.append(f"**Message Content:** {message_content}")
else: else:
embed.add_field(name="Reason", value=reason or "No reason provided.", inline=False) lines.append(f"**Reason:** {reason or 'No reason provided.'}")
if duration: if duration:
# Format duration nicely (e.g., "1 day", "2 hours 30 minutes")
# This is a simple version, could be made more robust
total_seconds = int(duration.total_seconds()) total_seconds = int(duration.total_seconds())
days, remainder = divmod(total_seconds, 86400) days, remainder = divmod(total_seconds, 86400)
hours, remainder = divmod(remainder, 3600) hours, remainder = divmod(remainder, 3600)
minutes, seconds = divmod(remainder, 60) minutes, seconds = divmod(remainder, 60)
duration_str = "" duration_str = ""
if days > 0: duration_str += f"{days}d " if days > 0:
if hours > 0: duration_str += f"{hours}h " duration_str += f"{days}d "
if minutes > 0: duration_str += f"{minutes}m " if hours > 0:
if seconds > 0 or not duration_str: duration_str += f"{seconds}s" duration_str += f"{hours}h "
if minutes > 0:
duration_str += f"{minutes}m "
if seconds > 0 or not duration_str:
duration_str += f"{seconds}s"
duration_str = duration_str.strip() duration_str = duration_str.strip()
lines.append(f"**Duration:** {duration_str}")
embed.add_field(name="Duration", value=duration_str, inline=True)
# Add expiration timestamp if applicable (e.g., for timeouts)
if action_type.upper() == "TIMEOUT": if action_type.upper() == "TIMEOUT":
expires_at = discord.utils.utcnow() + duration expires_at = discord.utils.utcnow() + duration
embed.add_field(name="Expires", value=f"<t:{int(expires_at.timestamp())}:R>", inline=True) lines.append(f"**Expires:** <t:{int(expires_at.timestamp())}:R>")
footer = (
f"AI Moderation Action • {guild.name} ({guild.id})" + (f" • Model: {ai_details.get('ai_model')}" if ai_details and ai_details.get('ai_model') else "")
if source == "AI_API": if source == "AI_API"
ai_model = ai_details.get("ai_model") if ai_details else None else f"Guild: {guild.name} ({guild.id})"
embed.set_footer( )
text=f"AI Moderation Action • {guild.name} ({guild.id})" + (f" • Model: {ai_model}" if ai_model else ""), return self.LogView(self.bot, action_title, embed_color, lines, footer)
icon_url="https://cdn-icons-png.flaticon.com/512/4712/4712035.png"
)
else:
embed.set_footer(text=f"Guild: {guild.name} ({guild.id})")
return embed
# --- View Command Callback --- # --- View Command Callback ---
@app_commands.checks.has_permissions(moderate_members=True) # Adjust permissions as needed @app_commands.checks.has_permissions(moderate_members=True) # Adjust permissions as needed
async def modlog_view_callback(self, interaction: Interaction, user: Optional[discord.User] = None): async def modlog_view_callback(self, interaction: Interaction, user: Optional[discord.User] = None):
@ -359,9 +361,14 @@ class ModLogCog(commands.Cog):
timestamp_str = record['timestamp'].strftime('%Y-%m-%d %H:%M:%S') timestamp_str = record['timestamp'].strftime('%Y-%m-%d %H:%M:%S')
reason_str = record['reason'] or "N/A" reason_str = record['reason'] or "N/A"
duration_str = f" ({record['duration_seconds']}s)" if record['duration_seconds'] else "" duration_str = f" ({record['duration_seconds']}s)" if record['duration_seconds'] else ""
target_disp = await self._fetch_user_display(record['target_user_id'], interaction.guild)
if record['moderator_id'] == 0:
mod_disp = "AI System"
else:
mod_disp = await self._fetch_user_display(record['moderator_id'], interaction.guild)
response_lines.append( response_lines.append(
f"`Case #{record['case_id']}` [{timestamp_str}] **{record['action_type']}** " f"`Case #{record['case_id']}` [{timestamp_str}] **{record['action_type']}** "
f"Target: <@{record['target_user_id']}> Mod: <@{record['moderator_id']}> " f"Target: {target_disp} Mod: {mod_disp} "
f"Reason: {reason_str}{duration_str}" f"Reason: {reason_str}{duration_str}"
) )
@ -408,7 +415,7 @@ class ModLogCog(commands.Cog):
duration = datetime.timedelta(seconds=record['duration_seconds']) if record['duration_seconds'] else None duration = datetime.timedelta(seconds=record['duration_seconds']) if record['duration_seconds'] else None
embed = self._format_log_embed( view = self._format_log_embed(
case_id, case_id,
moderator or Object(id=record['moderator_id']), # Fallback to Object if user not found moderator or Object(id=record['moderator_id']), # Fallback to Object if user not found
target or Object(id=record['target_user_id']), # Fallback to Object if user not found target or Object(id=record['target_user_id']), # Fallback to Object if user not found
@ -421,9 +428,10 @@ class ModLogCog(commands.Cog):
# Add log message link if available # Add log message link if available
if record['log_message_id'] and record['log_channel_id']: if record['log_message_id'] and record['log_channel_id']:
link = f"https://discord.com/channels/{record['guild_id']}/{record['log_channel_id']}/{record['log_message_id']}" link = f"https://discord.com/channels/{record['guild_id']}/{record['log_channel_id']}/{record['log_message_id']}"
embed.add_field(name="Log Message", value=f"[Jump to Log]({link})", inline=False) # Append jump link as extra line
view.footer_display.content += f" | [Jump to Log]({link})"
await interaction.followup.send(embed=embed, ephemeral=True) await interaction.followup.send(view=view, ephemeral=True)
@app_commands.checks.has_permissions(manage_guild=True) # Higher permission for editing reasons @app_commands.checks.has_permissions(manage_guild=True) # Higher permission for editing reasons
@ -455,7 +463,7 @@ class ModLogCog(commands.Cog):
log_channel = interaction.guild.get_channel(original_record['log_channel_id']) log_channel = interaction.guild.get_channel(original_record['log_channel_id'])
if log_channel and isinstance(log_channel, discord.TextChannel): if log_channel and isinstance(log_channel, discord.TextChannel):
log_message = await log_channel.fetch_message(original_record['log_message_id']) log_message = await log_channel.fetch_message(original_record['log_message_id'])
if log_message and log_message.author == self.bot.user and log_message.embeds: if log_message and log_message.author == self.bot.user:
# Re-fetch users/duration to reconstruct embed accurately # Re-fetch users/duration to reconstruct embed accurately
# Special handling for AI moderator (ID 0) to avoid Discord API 404 error # Special handling for AI moderator (ID 0) to avoid Discord API 404 error
if original_record['moderator_id'] == 0: if original_record['moderator_id'] == 0:
@ -476,7 +484,7 @@ class ModLogCog(commands.Cog):
duration = datetime.timedelta(seconds=original_record['duration_seconds']) if original_record['duration_seconds'] else None duration = datetime.timedelta(seconds=original_record['duration_seconds']) if original_record['duration_seconds'] else None
new_embed = self._format_log_embed( new_view = self._format_log_embed(
case_id, case_id,
moderator or Object(id=original_record['moderator_id']), moderator or Object(id=original_record['moderator_id']),
target or Object(id=original_record['target_user_id']), target or Object(id=original_record['target_user_id']),
@ -485,13 +493,11 @@ class ModLogCog(commands.Cog):
duration, duration,
interaction.guild interaction.guild
) )
# Add log message link again
link = f"https://discord.com/channels/{original_record['guild_id']}/{original_record['log_channel_id']}/{original_record['log_message_id']}" link = f"https://discord.com/channels/{original_record['guild_id']}/{original_record['log_channel_id']}/{original_record['log_message_id']}"
new_embed.add_field(name="Log Message", value=f"[Jump to Log]({link})", inline=False) new_view.footer_display.content += f" | [Jump to Log]({link}) | Updated By: {interaction.user.mention}"
new_embed.add_field(name="Updated Reason By", value=f"{interaction.user.mention}", inline=False) # Indicate update
await log_message.edit(embed=new_embed) await log_message.edit(view=new_view)
log.info(f"Successfully updated log message embed for case {case_id}") log.info(f"Successfully updated log message view for case {case_id}")
except discord.NotFound: except discord.NotFound:
log.warning(f"Original log message or channel not found for case {case_id} when updating reason.") log.warning(f"Original log message or channel not found for case {case_id} when updating reason.")
except discord.Forbidden: except discord.Forbidden:

View File

@ -30,6 +30,12 @@ class ModerationCog(commands.Cog):
# Add command group to the bot's tree # Add command group to the bot's tree
self.bot.tree.add_command(self.moderate_group) self.bot.tree.add_command(self.moderate_group)
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}]"
def register_commands(self): def register_commands(self):
"""Register all commands for this cog""" """Register all commands for this cog"""
@ -285,11 +291,12 @@ class ModerationCog(commands.Cog):
# ------------------------- # -------------------------
# Send confirmation message with DM status # Send confirmation message with DM status
target_text = self._user_display(member)
if send_dm: if send_dm:
dm_status = "✅ DM notification sent" if dm_sent else "❌ Could not send DM notification (user may have DMs disabled)" dm_status = "✅ DM notification sent" if dm_sent else "❌ Could not send DM notification (user may have DMs disabled)"
await interaction.response.send_message(f"🔨 **Banned {member.mention}**! Reason: {reason or 'No reason provided'}\n{dm_status}") await interaction.response.send_message(f"🔨 **Banned {target_text}**! Reason: {reason or 'No reason provided'}\n{dm_status}")
else: else:
await interaction.response.send_message(f"🔨 **Banned {member.mention}**! Reason: {reason or 'No reason provided'}\n⚠️ DM notification was disabled") await interaction.response.send_message(f"🔨 **Banned {target_text}**! Reason: {reason or 'No reason provided'}\n⚠️ DM notification was disabled")
except discord.Forbidden: except discord.Forbidden:
await interaction.response.send_message("❌ I don't have permission to ban this member.", ephemeral=True) await interaction.response.send_message("❌ I don't have permission to ban this member.", ephemeral=True)
except discord.HTTPException as e: except discord.HTTPException as e:
@ -349,7 +356,7 @@ class ModerationCog(commands.Cog):
# ------------------------- # -------------------------
# Send confirmation message # Send confirmation message
await interaction.response.send_message(f"🔓 **Unbanned {banned_user}**! Reason: {reason or 'No reason provided'}") await interaction.response.send_message(f"🔓 **Unbanned {self._user_display(banned_user)}**! Reason: {reason or 'No reason provided'}")
except discord.Forbidden: except discord.Forbidden:
await interaction.response.send_message("❌ I don't have permission to unban this user.", ephemeral=True) await interaction.response.send_message("❌ I don't have permission to unban this user.", ephemeral=True)
except discord.HTTPException as e: except discord.HTTPException as e:
@ -429,7 +436,7 @@ class ModerationCog(commands.Cog):
# Send confirmation message with DM status # Send confirmation message with DM status
dm_status = "✅ DM notification sent" if dm_sent else "❌ Could not send DM notification (user may have DMs disabled)" dm_status = "✅ DM notification sent" if dm_sent else "❌ Could not send DM notification (user may have DMs disabled)"
await interaction.response.send_message(f"👢 **Kicked {member.mention}**! Reason: {reason or 'No reason provided'}\n{dm_status}") await interaction.response.send_message(f"👢 **Kicked {self._user_display(member)}**! Reason: {reason or 'No reason provided'}\n{dm_status}")
except discord.Forbidden: except discord.Forbidden:
await interaction.response.send_message("❌ I don't have permission to kick this member.", ephemeral=True) await interaction.response.send_message("❌ I don't have permission to kick this member.", ephemeral=True)
except discord.HTTPException as e: except discord.HTTPException as e:
@ -541,7 +548,7 @@ class ModerationCog(commands.Cog):
# Send confirmation message with DM status # Send confirmation message with DM status
dm_status = "✅ DM notification sent" if dm_sent else "❌ Could not send DM notification (user may have DMs disabled)" dm_status = "✅ DM notification sent" if dm_sent else "❌ Could not send DM notification (user may have DMs disabled)"
await safe_followup(f"⏰ **Timed out {member.mention}** for {duration}! Reason: {reason or 'No reason provided'}\n{dm_status}") await safe_followup(f"⏰ **Timed out {self._user_display(member)}** for {duration}! Reason: {reason or 'No reason provided'}\n{dm_status}")
except discord.Forbidden: except discord.Forbidden:
await safe_followup("❌ I don't have permission to timeout this member.", ephemeral=True) await safe_followup("❌ I don't have permission to timeout this member.", ephemeral=True)
except discord.HTTPException as e: except discord.HTTPException as e:
@ -606,7 +613,7 @@ class ModerationCog(commands.Cog):
# Send confirmation message with DM status # Send confirmation message with DM status
dm_status = "✅ DM notification sent" if dm_sent else "❌ Could not send DM notification (user may have DMs disabled)" dm_status = "✅ DM notification sent" if dm_sent else "❌ Could not send DM notification (user may have DMs disabled)"
await interaction.response.send_message(f"⏰ **Removed timeout from {member.mention}**! Reason: {reason or 'No reason provided'}\n{dm_status}") await interaction.response.send_message(f"⏰ **Removed timeout from {self._user_display(member)}**! Reason: {reason or 'No reason provided'}\n{dm_status}")
except discord.Forbidden: except discord.Forbidden:
await interaction.response.send_message("❌ I don't have permission to remove the timeout from this member.", ephemeral=True) await interaction.response.send_message("❌ I don't have permission to remove the timeout from this member.", ephemeral=True)
except discord.HTTPException as e: except discord.HTTPException as e:
@ -645,7 +652,7 @@ class ModerationCog(commands.Cog):
logger.info(f"{len(deleted)} messages from user {user} (ID: {user.id}) were purged from channel {interaction.channel.name} (ID: {interaction.channel.id}) in {interaction.guild.name} (ID: {interaction.guild.id}) by {interaction.user} (ID: {interaction.user.id}).") logger.info(f"{len(deleted)} messages from user {user} (ID: {user.id}) were purged from channel {interaction.channel.name} (ID: {interaction.channel.id}) in {interaction.guild.name} (ID: {interaction.guild.id}) by {interaction.user} (ID: {interaction.user.id}).")
# Send confirmation message # Send confirmation message
await interaction.followup.send(f"🧹 **Purged {len(deleted)} messages** from {user.mention}!", ephemeral=True) await interaction.followup.send(f"🧹 **Purged {len(deleted)} messages** from {self._user_display(user)}!", ephemeral=True)
else: else:
# Delete messages from anyone # Delete messages from anyone
deleted = await interaction.channel.purge(limit=amount) deleted = await interaction.channel.purge(limit=amount)
@ -699,7 +706,7 @@ class ModerationCog(commands.Cog):
# ------------------------- # -------------------------
# Send warning message in the channel # Send warning message in the channel
await interaction.response.send_message(f"⚠️ **{member.mention} has been warned**! Reason: {reason}") await interaction.response.send_message(f"⚠️ **{self._user_display(member)} has been warned**! Reason: {reason}")
# Try to DM the user about the warning # Try to DM the user about the warning
try: try:
@ -788,7 +795,7 @@ class ModerationCog(commands.Cog):
infractions = await mod_log_db.get_user_mod_logs(self.bot.pg_pool, interaction.guild.id, member.id) infractions = await mod_log_db.get_user_mod_logs(self.bot.pg_pool, interaction.guild.id, member.id)
if not infractions: if not infractions:
await interaction.response.send_message(f"No infractions found for {member.mention}.", ephemeral=True) await interaction.response.send_message(f"No infractions found for {self._user_display(member)}.", ephemeral=True)
return return
embed = discord.Embed( embed = discord.Embed(
@ -901,11 +908,11 @@ class ModerationCog(commands.Cog):
reason=f"Cleared {deleted_count} infractions. Reason: {reason or 'Not specified'}", reason=f"Cleared {deleted_count} infractions. Reason: {reason or 'Not specified'}",
duration=None duration=None
) )
await interaction_confirm.response.edit_message(content=f"✅ Successfully cleared {deleted_count} infractions for {member.mention}. Reason: {reason or 'Not specified'}", view=None) await interaction_confirm.response.edit_message(content=f"✅ Successfully cleared {deleted_count} infractions for {self._user_display(member)}. Reason: {reason or 'Not specified'}", view=None)
elif deleted_count == 0: elif deleted_count == 0:
await interaction_confirm.response.edit_message(content=f" No infractions found for {member.mention} to clear.", view=None) await interaction_confirm.response.edit_message(content=f" No infractions found for {self._user_display(member)} to clear.", view=None)
else: # Should not happen if 0 is returned for no logs else: # Should not happen if 0 is returned for no logs
await interaction_confirm.response.edit_message(content=f"❌ Failed to clear infractions for {member.mention}. An error occurred.", view=None) await interaction_confirm.response.edit_message(content=f"❌ Failed to clear infractions for {self._user_display(member)}. An error occurred.", view=None)
async def cancel_callback(interaction_cancel: discord.Interaction): async def cancel_callback(interaction_cancel: discord.Interaction):
if interaction_cancel.user.id != interaction.user.id: if interaction_cancel.user.id != interaction.user.id:
@ -919,7 +926,7 @@ class ModerationCog(commands.Cog):
view.add_item(cancel_button) view.add_item(cancel_button)
await interaction.response.send_message( await interaction.response.send_message(
f"⚠️ Are you sure you want to clear **ALL** infractions for {member.mention}?\n" f"⚠️ Are you sure you want to clear **ALL** infractions for {self._user_display(member)}?\n"
f"This action is irreversible. Reason: {reason or 'Not specified'}", f"This action is irreversible. Reason: {reason or 'Not specified'}",
view=view, view=view,
ephemeral=True ephemeral=True
@ -1044,7 +1051,7 @@ class ModerationCog(commands.Cog):
# Send confirmation message with DM status # Send confirmation message with DM status
dm_status = "✅ DM notification sent" if dm_sent else "❌ Could not send DM notification (user may have DMs disabled)" dm_status = "✅ DM notification sent" if dm_sent else "❌ Could not send DM notification (user may have DMs disabled)"
await ctx.reply(f"⏰ **Timed out {member.mention}** for {duration}! Reason: {reason or 'No reason provided'}\n{dm_status}") await ctx.reply(f"⏰ **Timed out {self._user_display(member)}** for {duration}! Reason: {reason or 'No reason provided'}\n{dm_status}")
except discord.Forbidden: except discord.Forbidden:
await ctx.reply("❌ I don't have permission to timeout this member.") await ctx.reply("❌ I don't have permission to timeout this member.")
except discord.HTTPException as e: except discord.HTTPException as e:
@ -1124,7 +1131,7 @@ class ModerationCog(commands.Cog):
# Send confirmation message with DM status # Send confirmation message with DM status
dm_status = "✅ DM notification sent" if dm_sent else "❌ Could not send DM notification (user may have DMs disabled)" dm_status = "✅ DM notification sent" if dm_sent else "❌ Could not send DM notification (user may have DMs disabled)"
await ctx.reply(f"⏰ **Removed timeout from {member.mention}**! Reason: {reason or 'No reason provided'}\n{dm_status}") await ctx.reply(f"⏰ **Removed timeout from {self._user_display(member)}**! Reason: {reason or 'No reason provided'}\n{dm_status}")
except discord.Forbidden: except discord.Forbidden:
await ctx.reply("❌ I don't have permission to remove the timeout from this member.") await ctx.reply("❌ I don't have permission to remove the timeout from this member.")
except discord.HTTPException as e: except discord.HTTPException as e:

View File

@ -439,9 +439,10 @@ class RoleManagementCog(commands.Cog):
# Attempt to DM the user # Attempt to DM the user
try: try:
role_info = f"{role.name} (ID: {role.id})"
dm_embed = discord.Embed( dm_embed = discord.Embed(
title="Role Added", title="Role Added",
description=f"The role {role.mention} was added to you in **{interaction.guild.name}**.", description=f"The role {role_info} was added to you in **{interaction.guild.name}**.",
color=role.color color=role.color
) )
dm_embed.add_field(name="Added by", value=interaction.user.mention, inline=True) dm_embed.add_field(name="Added by", value=interaction.user.mention, inline=True)
@ -506,9 +507,10 @@ class RoleManagementCog(commands.Cog):
# Attempt to DM the user # Attempt to DM the user
try: try:
role_info = f"{role.name} (ID: {role.id})"
dm_embed = discord.Embed( dm_embed = discord.Embed(
title="Role Removed", title="Role Removed",
description=f"The role {role.mention} was removed from you in **{interaction.guild.name}**.", description=f"The role {role_info} was removed from you in **{interaction.guild.name}**.",
color=role.color color=role.color
) )
dm_embed.add_field(name="Removed by", value=interaction.user.mention, inline=True) dm_embed.add_field(name="Removed by", value=interaction.user.mention, inline=True)