From dc01bb62df751fa40b6e95a305ab05dc63500dbb Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 5 Jun 2025 17:46:25 +0000 Subject: [PATCH 1/4] refactor logging views --- cogs/logging_cog.py | 60 +++++++++++------- cogs/mod_log_cog.py | 148 ++++++++++++++++++++------------------------ 2 files changed, 104 insertions(+), 104 deletions(-) diff --git a/cogs/logging_cog.py b/cogs/logging_cog.py index a5e4390..3ece6e4 100644 --- a/cogs/logging_cog.py +++ b/cogs/logging_cog.py @@ -1,5 +1,6 @@ import discord from discord.ext import commands, tasks +from discord import ui import datetime import asyncio import aiohttp # Added for webhook sending @@ -54,6 +55,30 @@ class LoggingCog(commands.Cog): else: asyncio.create_task(self.start_audit_log_poller_when_ready()) # Keep this for initial start + class LogView(ui.LayoutView): + """Simple view for log messages.""" + + 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 @@ -109,8 +134,8 @@ class LoggingCog(commands.Cog): await self.session.close() log.info("aiohttp ClientSession closed for LoggingCog.") - async def _send_log_embed(self, guild: discord.Guild, embed: discord.Embed): - """Sends the log embed via the configured webhook for the guild.""" + async def _send_log_embed(self, guild: discord.Guild, embed: ui.LayoutView): + """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 @@ -124,9 +149,9 @@ class LoggingCog(commands.Cog): try: webhook = discord.Webhook.from_url(webhook_url, session=self.session) await webhook.send( - embed=embed, - username=f"{self.bot.user.name} Logs", # Optional: Customize webhook appearance - avatar_url=self.bot.user.display_avatar.url # Optional: Use bot's avatar + view=embed, + username=f"{self.bot.user.name} Logs", + avatar_url=self.bot.user.display_avatar.url, ) # log.debug(f"Sent log embed via webhook for guild {guild.id}") # Can be noisy except ValueError: @@ -145,26 +170,17 @@ class LoggingCog(commands.Cog): 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: - """Creates a standardized log embed.""" - embed = discord.Embed(title=title, description=description, color=color, timestamp=datetime.datetime.now(datetime.timezone.utc)) - 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 _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: 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"): - """Adds an ID to the embed footer if possible.""" + 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 footer text if possible.""" target_id = obj_id or (obj.id if obj else None) - if target_id: - existing_footer = embed.footer.text or "" + if target_id and hasattr(embed, "footer_display"): + existing_footer = embed.footer_display.content or "" 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: """Checks if logging is enabled for a specific event key in a guild.""" diff --git a/cogs/mod_log_cog.py b/cogs/mod_log_cog.py index f518967..4f8c359 100644 --- a/cogs/mod_log_cog.py +++ b/cogs/mod_log_cog.py @@ -1,6 +1,6 @@ import discord 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 logging from typing import Optional, Union, Dict, Any @@ -32,6 +32,21 @@ class ModLogCog(commands.Cog): # Add command group to the bot's tree 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): @@ -220,8 +235,8 @@ class ModLogCog(commands.Cog): # Optionally update DB to remove channel ID? Or just leave it. return - # 3. Format and send embed - embed = self._format_log_embed( + # 3. Format and send view + view = self._format_log_embed( case_id=case_id, moderator=moderator, # Pass the object for display formatting target=target, @@ -233,7 +248,7 @@ class ModLogCog(commands.Cog): ai_details=ai_details, 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 await mod_log_db.update_mod_log_message_details(self.pool, case_id, log_message.id, log_channel.id) @@ -241,6 +256,7 @@ class ModLogCog(commands.Cog): except Exception as 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( self, case_id: int, @@ -252,9 +268,9 @@ class ModLogCog(commands.Cog): guild: discord.Guild, source: str = "BOT", ai_details: Optional[Dict[str, Any]] = None, - moderator_id_override: Optional[int] = None - ) -> Embed: - """Helper function to create the standard log embed.""" + moderator_id_override: Optional[int] = None, + ) -> ui.LayoutView: + """Helper function to create the standard log view.""" color_map = { "BAN": Color.red(), "UNBAN": Color.green(), @@ -265,87 +281,56 @@ class ModLogCog(commands.Cog): "AI_ALERT": Color.purple(), "AI_DELETE_REQUESTED": Color.dark_grey(), } - # Use a distinct color for AI actions - if source == "AI_API": - embed_color = Color.blurple() - else: - embed_color = color_map.get(action_type.upper(), Color.greyple()) + embed_color = Color.blurple() if source == "AI_API" else 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 = f"{action_title_prefix} | Case #{case_id}" - - embed = Embed( - title=action_title, - color=embed_color, - timestamp=discord.utils.utcnow() - ) - target_display = self._format_user(target, guild) - - if source == "AI_API": - moderator_display = f"AI System (ID: {moderator_id_override or 'Unknown'})" - else: - moderator_display = self._format_user(moderator, guild) - - - 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 + moderator_display = ( + f"AI System (ID: {moderator_id_override or 'Unknown'})" if source == "AI_API" else self._format_user(moderator, guild) + ) + lines = [f"**User:** {target_display}", f"**Moderator:** {moderator_display}"] if ai_details: - if 'rule_violated' in ai_details: - embed.add_field(name="Rule Violated", value=ai_details['rule_violated'], inline=True) - 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'] - embed.add_field(name="Reason / AI Reasoning", value=reason_to_display or "No reason provided.", inline=False) - # Optionally add bot reason separately if both exist and differ - if reason and reason != ai_details['reasoning']: - embed.add_field(name="Original Bot Reason", value=reason, inline=False) + if "rule_violated" in ai_details: + lines.append(f"**Rule Violated:** {ai_details['rule_violated']}") + if "reasoning" in ai_details: + reason_to_display = reason or ai_details["reasoning"] + lines.append(f"**Reason / AI Reasoning:** {reason_to_display or 'No reason provided.'}") + if reason and reason != ai_details["reasoning"]: + lines.append(f"**Original Bot Reason:** {reason}") else: - embed.add_field(name="Reason", value=reason or "No reason provided.", inline=False) - - # Add full message content if available - 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'] + lines.append(f"**Reason:** {reason or 'No reason provided.'}") + if "message_content" in ai_details: + message_content = ai_details["message_content"] if len(message_content) > 1000: message_content = message_content[:997] + "..." - embed.add_field(name="Message Content", value=message_content, inline=False) + lines.append(f"**Message Content:** {message_content}") 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: - # 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()) days, remainder = divmod(total_seconds, 86400) hours, remainder = divmod(remainder, 3600) minutes, seconds = divmod(remainder, 60) duration_str = "" - if days > 0: duration_str += f"{days}d " - if hours > 0: 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" + if days > 0: + duration_str += f"{days}d " + if hours > 0: + 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() - - embed.add_field(name="Duration", value=duration_str, inline=True) - # Add expiration timestamp if applicable (e.g., for timeouts) + lines.append(f"**Duration:** {duration_str}") if action_type.upper() == "TIMEOUT": - expires_at = discord.utils.utcnow() + duration - embed.add_field(name="Expires", value=f"", inline=True) - - - if source == "AI_API": - ai_model = ai_details.get("ai_model") if ai_details else None - embed.set_footer( - text=f"AI Moderation Action • {guild.name} ({guild.id})" + (f" • Model: {ai_model}" if ai_model else ""), - 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 - + expires_at = discord.utils.utcnow() + duration + lines.append(f"**Expires:** ") + 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" + else f"Guild: {guild.name} ({guild.id})" + ) + return self.LogView(self.bot, action_title, embed_color, lines, footer) # --- View Command Callback --- @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): @@ -430,7 +415,7 @@ class ModLogCog(commands.Cog): 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, 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 @@ -443,9 +428,10 @@ class ModLogCog(commands.Cog): # Add log message link if available 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']}" - 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 @@ -477,7 +463,7 @@ class ModLogCog(commands.Cog): log_channel = interaction.guild.get_channel(original_record['log_channel_id']) if log_channel and isinstance(log_channel, discord.TextChannel): 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 # Special handling for AI moderator (ID 0) to avoid Discord API 404 error if original_record['moderator_id'] == 0: @@ -498,7 +484,7 @@ class ModLogCog(commands.Cog): 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, moderator or Object(id=original_record['moderator_id']), target or Object(id=original_record['target_user_id']), @@ -507,13 +493,11 @@ class ModLogCog(commands.Cog): duration, 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']}" - new_embed.add_field(name="Log Message", value=f"[Jump to Log]({link})", inline=False) - new_embed.add_field(name="Updated Reason By", value=f"{interaction.user.mention}", inline=False) # Indicate update + new_view.footer_display.content += f" | [Jump to Log]({link}) | Updated By: {interaction.user.mention}" - await log_message.edit(embed=new_embed) - log.info(f"Successfully updated log message embed for case {case_id}") + await log_message.edit(view=new_view) + log.info(f"Successfully updated log message view for case {case_id}") except discord.NotFound: log.warning(f"Original log message or channel not found for case {case_id} when updating reason.") except discord.Forbidden: From 1e6b1ce91dc1fb61ed6aa4b61103ad58503b46a7 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 5 Jun 2025 17:57:25 +0000 Subject: [PATCH 2/4] fix: implement LogView compatibility --- cogs/logging_cog.py | 42 ++++++++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/cogs/logging_cog.py b/cogs/logging_cog.py index 3ece6e4..18ce177 100644 --- a/cogs/logging_cog.py +++ b/cogs/logging_cog.py @@ -56,28 +56,50 @@ class LoggingCog(commands.Cog): asyncio.create_task(self.start_audit_log_poller_when_ready()) # Keep this for initial start class LogView(ui.LayoutView): - """Simple view for log messages.""" + """Simple view for log messages with helper methods.""" def __init__(self, bot: commands.Bot, title: str, description: str, color: discord.Color, author: Optional[discord.abc.User], footer: Optional[str]): super().__init__(timeout=None) - 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}**")) + self.container = ui.Container(accent_colour=color) + self.add_item(self.container) + + self.header = ui.Section(accessory=(ui.Thumbnail(media=author.display_avatar.url) + if author else None)) + self.header.add_item(ui.TextDisplay(f"**{title}**")) if description: - header.add_item(ui.TextDisplay(description)) - container.add_item(header) + self.header.add_item(ui.TextDisplay(description)) + self.container.add_item(self.header) - container.add_item(ui.Separator(spacing=discord.SeparatorSpacing.small)) + self.fields_section = ui.Section() + self.container.add_item(self.fields_section) + + self.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) + self.container.add_item(self.footer_display) + + # --- Compatibility helpers --- + def add_field(self, name: str, value: str, inline: bool = False): + """Mimic Embed.add_field by appending a bolded name/value line.""" + self.fields_section.add_item(ui.TextDisplay(f"**{name}:** {value}")) + + def set_footer(self, text: str): + """Mimic Embed.set_footer by replacing the footer text display.""" + self.footer_display.content = text + + def set_author(self, name: str, icon_url: Optional[str] = None): + """Mimic Embed.set_author by adjusting the header section.""" + self.header.clear_items() + if icon_url: + self.header.accessory = ui.Thumbnail(media=icon_url) + else: + self.header.accessory = None + self.header.add_item(ui.TextDisplay(name)) def _user_display(self, user: Union[discord.Member, discord.User]) -> str: """Return display name, username and ID string for a user.""" From fdb9d4b7cbd894244e7aeb7aba1821e740acb2ef Mon Sep 17 00:00:00 2001 From: Slipstream Date: Thu, 5 Jun 2025 18:29:34 +0000 Subject: [PATCH 3/4] Applying previous commit. --- cogs/logging_cog.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cogs/logging_cog.py b/cogs/logging_cog.py index 18ce177..78e444d 100644 --- a/cogs/logging_cog.py +++ b/cogs/logging_cog.py @@ -62,7 +62,6 @@ class LoggingCog(commands.Cog): color: discord.Color, author: Optional[discord.abc.User], footer: Optional[str]): super().__init__(timeout=None) - self.container = ui.Container(accent_colour=color) self.add_item(self.container) @@ -73,7 +72,7 @@ class LoggingCog(commands.Cog): self.header.add_item(ui.TextDisplay(description)) self.container.add_item(self.header) - self.fields_section = ui.Section() + self.fields_section = ui.Section(accessory=None) self.container.add_item(self.fields_section) self.container.add_item(ui.Separator(spacing=discord.SeparatorSpacing.small)) @@ -100,7 +99,6 @@ class LoggingCog(commands.Cog): else: self.header.accessory = None self.header.add_item(ui.TextDisplay(name)) - def _user_display(self, user: Union[discord.Member, discord.User]) -> str: """Return display name, username and ID string for a user.""" display = user.display_name if isinstance(user, discord.Member) else user.name From 2a57175e47d24910e2f8c70a25fd99d4df6d2358 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 5 Jun 2025 18:31:39 +0000 Subject: [PATCH 4/4] fix logging view field section --- cogs/logging_cog.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cogs/logging_cog.py b/cogs/logging_cog.py index 78e444d..5b019da 100644 --- a/cogs/logging_cog.py +++ b/cogs/logging_cog.py @@ -72,6 +72,7 @@ class LoggingCog(commands.Cog): self.header.add_item(ui.TextDisplay(description)) self.container.add_item(self.header) + # Section to hold log fields with no accessory self.fields_section = ui.Section(accessory=None) self.container.add_item(self.fields_section)