diff --git a/api_service/api_server.py b/api_service/api_server.py index 20315d4..2765fb5 100644 --- a/api_service/api_server.py +++ b/api_service/api_server.py @@ -16,6 +16,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic import BaseModel, Field from functools import lru_cache from contextlib import asynccontextmanager +from enum import Enum # --- Logging Configuration --- # Configure logging @@ -60,6 +61,9 @@ class ApiSettings(BaseSettings): REDIS_PORT: int = 6379 REDIS_PASSWORD: Optional[str] = None # Optional + # Secret key for AI Moderation API endpoint + MOD_LOG_API_SECRET: Optional[str] = None + model_config = SettingsConfigDict( env_file=dotenv_path, env_file_encoding='utf-8', @@ -2611,6 +2615,7 @@ async def get_token(user_id: str = Depends(verify_discord_token)): # Return only the access token, not the full token data return {"access_token": token_data.get("access_token")} + @api_app.get("/token/{user_id}") @discordapi_app.get("/token/{user_id}") async def get_token_by_user_id(user_id: str): diff --git a/api_service/dashboard_api_endpoints.py b/api_service/dashboard_api_endpoints.py index d1920dc..333272f 100644 --- a/api_service/dashboard_api_endpoints.py +++ b/api_service/dashboard_api_endpoints.py @@ -107,7 +107,34 @@ class CommandInfo(BaseModel): enabled: bool = True cog_name: Optional[str] = None +class Guild(BaseModel): + id: str + name: str + icon_url: Optional[str] = None + # --- Endpoints --- + +@router.get("/user-guilds", response_model=List[Guild]) +async def get_user_guilds( + user: dict = Depends(get_dashboard_user) +): + """Get all guilds the user is an admin of.""" + try: + # This would normally fetch guilds from Discord API or the bot + # For now, we'll return a mock response + # TODO: Replace mock data with actual API call to Discord + guilds = [ + Guild(id="123456789", name="My Awesome Server", icon_url="https://cdn.discordapp.com/icons/123456789/abc123def456ghi789jkl012mno345pqr.png"), + Guild(id="987654321", name="Another Great Server", icon_url="https://cdn.discordapp.com/icons/987654321/zyx987wvu654tsr321qpo098mlk765jih.png") + ] + return guilds + except Exception as e: + log.error(f"Error getting user guilds: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error getting user guilds: {str(e)}" + ) + @router.get("/guilds/{guild_id}/channels", response_model=List[Channel]) async def get_guild_channels( guild_id: int, @@ -118,6 +145,7 @@ async def get_guild_channels( try: # This would normally fetch channels from Discord API or the bot # For now, we'll return a mock response + # TODO: Replace mock data with actual API call to Discord channels = [ Channel(id="123456789", name="general", type=0), Channel(id="123456790", name="welcome", type=0), @@ -142,6 +170,7 @@ async def get_guild_roles( try: # This would normally fetch roles from Discord API or the bot # For now, we'll return a mock response + # TODO: Replace mock data with actual API call to Discord roles = [ Role(id="123456789", name="@everyone", color=0, position=0, permissions="0"), Role(id="123456790", name="Admin", color=16711680, position=1, permissions="8"), @@ -166,6 +195,7 @@ async def get_guild_commands( try: # This would normally fetch commands from the bot # For now, we'll return a mock response + # TODO: Replace mock data with actual bot command introspection commands = [ Command(name="help", description="Show help message"), Command(name="ping", description="Check bot latency"), @@ -651,6 +681,7 @@ async def sync_guild_commands( # This endpoint would trigger a command sync for the guild # In a real implementation, this would communicate with the bot to sync commands # For now, we'll just return a success message + # TODO: Implement actual command syncing logic return {"message": "Command sync requested. This may take a moment to complete."} except Exception as e: log.error(f"Error syncing commands for guild {guild_id}: {e}") @@ -1168,6 +1199,7 @@ async def get_conversations( try: # This would normally fetch conversations from the database # For now, we'll return a mock response + # TODO: Implement actual conversation fetching conversations = [ Conversation( id="1", @@ -1201,6 +1233,7 @@ async def get_conversation_messages( try: # This would normally fetch messages from the database # For now, we'll return a mock response + # TODO: Implement actual message fetching messages = [ Message( id="1", diff --git a/api_service/dashboard_web/index.html b/api_service/dashboard_web/index.html index 4caa3f4..0f5f17b 100644 --- a/api_service/dashboard_web/index.html +++ b/api_service/dashboard_web/index.html @@ -29,7 +29,23 @@ - + +
Choose the server you want to manage.
+Settings for the selected server.
Enable/disable modules and commands for the selected server.
Loading your servers...
'; // Show loading state + + API.get('/dashboard/api/user-guilds') + .then(guilds => { + serverListContainer.innerHTML = ''; // Clear loading state + + if (!guilds || guilds.length === 0) { + serverListContainer.innerHTML = 'No servers found where you have admin permissions.
'; + return; + } + + guilds.forEach(guild => { + const guildElement = document.createElement('div'); + guildElement.className = 'server-select-item card'; // Add card class for styling + guildElement.style.cursor = 'pointer'; + guildElement.dataset.guildId = guild.id; + + const iconElement = document.createElement('img'); + iconElement.className = 'server-icon'; + iconElement.src = guild.icon_url || 'img/default-icon.png'; // Provide a default icon path + iconElement.alt = `${guild.name} icon`; + iconElement.width = 50; + iconElement.height = 50; + + const nameElement = document.createElement('span'); + nameElement.className = 'server-name'; + nameElement.textContent = guild.name; + + guildElement.appendChild(iconElement); + guildElement.appendChild(nameElement); + + guildElement.addEventListener('click', () => { + console.log(`Server selected: ${guild.name} (${guild.id})`); + // Store selected guild ID + localStorage.setItem('selectedGuildId', guild.id); + window.selectedGuildId = guild.id; + window.currentSettingsGuildId = null; // Reset loaded settings tracker + + // Hide server selection and show dashboard + const serverSelectSection = document.getElementById('server-select-section'); + const dashboardContainer = document.getElementById('dashboard-container'); + if (serverSelectSection) serverSelectSection.style.display = 'none'; + if (dashboardContainer) dashboardContainer.style.display = 'block'; + + // Load data for the selected guild and show the default section + loadDashboardData(); // Now loads data for the selected guild + showSection('server-settings'); // Show the server settings section by default + }); + + serverListContainer.appendChild(guildElement); + }); + }) + .catch(error => { + console.error('Error loading user guilds:', error); + serverListContainer.innerHTML = 'Error loading servers. Please try again.
'; + Toast.error('Failed to load your servers.'); + }); +} + + +/** + * Load initial dashboard data *after* a server has been selected */ function loadDashboardData() { - // Load guilds for server select - loadGuilds(); + if (!window.selectedGuildId) { + console.warn('loadDashboardData called without a selected guild ID.'); + showServerSelection(); // Redirect back to selection if no guild is selected + return; + } + console.log(`Loading dashboard data for guild: ${window.selectedGuildId}`); + // No longer need to load the general guild list here. + // Specific sections will load their data via showSection or dedicated functions. + // We might load some initial settings common to multiple sections here if needed. + // For now, let's ensure the basic settings are loaded if the user lands on a relevant page. + loadGuildSettings(window.selectedGuildId); } /** @@ -401,9 +571,11 @@ function showBotTokenMissingError() { } /** - * Load guilds for server select dropdown + * Load guilds for the *original* server select dropdown (now potentially redundant) + * Kept for reference or potential future use, but not called by default flow anymore. */ function loadGuilds() { + console.warn("loadGuilds function called - this might be redundant now."); const guildSelect = document.getElementById('guild-select'); if (!guildSelect) return; @@ -451,10 +623,26 @@ function loadGuilds() { } /** - * Load settings for a specific guild - * @param {string} guildId - The guild ID + * Load settings for the currently selected guild (window.selectedGuildId) + * @param {string} guildId - The guild ID to load settings for */ function loadGuildSettings(guildId) { + // Prevent reloading if settings for this guild are already loaded + if (window.currentSettingsGuildId === guildId) { + console.log(`Settings for guild ${guildId} already loaded.`); + // Ensure the forms are visible if navigating back + const settingsForm = document.getElementById('settings-form'); + const welcomeSettingsForm = document.getElementById('welcome-settings-form'); + const modulesSettingsForm = document.getElementById('modules-settings-form'); + const permissionsSettingsForm = document.getElementById('permissions-settings-form'); + if (settingsForm) settingsForm.style.display = 'block'; + if (welcomeSettingsForm) welcomeSettingsForm.style.display = 'block'; + if (modulesSettingsForm) modulesSettingsForm.style.display = 'block'; + if (permissionsSettingsForm) permissionsSettingsForm.style.display = 'block'; + return; + } + console.log(`Loading settings for guild: ${guildId}`); + const settingsForm = document.getElementById('settings-form'); const welcomeSettingsForm = document.getElementById('welcome-settings-form'); const modulesSettingsForm = document.getElementById('modules-settings-form'); @@ -498,6 +686,9 @@ function loadGuildSettings(guildId) { loadGuildRoles(guildId); loadGuildCommands(guildId); + // Mark settings as loaded for this guild + window.currentSettingsGuildId = guildId; + // Set up event listeners for buttons setupSaveSettingsButtons(guildId); setupWelcomeLeaveTestButtons(guildId); @@ -1370,5 +1561,3 @@ function setupWelcomeLeaveTestButtons(guildId) { }); } } - - diff --git a/cogs/mod_log_cog.py b/cogs/mod_log_cog.py new file mode 100644 index 0000000..f2bb002 --- /dev/null +++ b/cogs/mod_log_cog.py @@ -0,0 +1,395 @@ +import discord +from discord.ext import commands +from discord import app_commands, Interaction, Embed, Color, User, Member, Object +import asyncpg +import logging +from typing import Optional, Union, Dict, Any +import datetime + +# Assuming db functions are in discordbot.db.mod_log_db +from ..db import mod_log_db +# Assuming settings manager is available +from ..settings_manager import SettingsManager # Adjust import path if necessary + +log = logging.getLogger(__name__) + +class ModLogCog(commands.Cog): + """Cog for handling integrated moderation logging and related commands.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.settings_manager: SettingsManager = bot.settings_manager # Assuming settings_manager is attached to bot + self.pool: asyncpg.Pool = bot.pool # Assuming pool is attached to bot + + # Create the main command group for this cog + self.modlog_group = app_commands.Group( + name="modlog", + description="Commands for viewing and managing moderation logs" + ) + + # Register commands within the group + self.register_commands() + + # Add command group to the bot's tree + self.bot.tree.add_command(self.modlog_group) + + def register_commands(self): + """Register all commands for this cog""" + + # --- View Command --- + view_command = app_commands.Command( + name="view", + description="View moderation logs for a user or the server", + callback=self.modlog_view_callback, + parent=self.modlog_group + ) + app_commands.describe( + user="Optional: The user whose logs you want to view" + )(view_command) + self.modlog_group.add_command(view_command) + + # --- Case Command --- + case_command = app_commands.Command( + name="case", + description="View details for a specific moderation case ID", + callback=self.modlog_case_callback, + parent=self.modlog_group + ) + app_commands.describe( + case_id="The ID of the moderation case to view" + )(case_command) + self.modlog_group.add_command(case_command) + + # --- Reason Command --- + reason_command = app_commands.Command( + name="reason", + description="Update the reason for a specific moderation case ID", + callback=self.modlog_reason_callback, + parent=self.modlog_group + ) + app_commands.describe( + case_id="The ID of the moderation case to update", + new_reason="The new reason for the moderation action" + )(reason_command) + self.modlog_group.add_command(reason_command) + + # --- Core Logging Function --- + + async def log_action( + self, + guild: discord.Guild, + moderator: Union[User, Member], # For bot actions + target: Union[User, Member, Object], # Can be user, member, or just an ID object + action_type: str, + reason: Optional[str], + duration: Optional[datetime.timedelta] = None, + source: str = "BOT", # Default source is the bot itself + ai_details: Optional[Dict[str, Any]] = None, # Details from AI API + moderator_id_override: Optional[int] = None # Allow overriding moderator ID for AI source + ): + """Logs a moderation action to the database and configured channel.""" + if not guild: + log.warning("Attempted to log action without guild context.") + return + + guild_id = guild.id + # Use override if provided (for AI source), otherwise use moderator object ID + moderator_id = moderator_id_override if moderator_id_override is not None else moderator.id + target_user_id = target.id + duration_seconds = int(duration.total_seconds()) if duration else None + + # 1. Add initial log entry to DB + case_id = await mod_log_db.add_mod_log( + self.pool, guild_id, moderator_id, target_user_id, + action_type, reason, duration_seconds + ) + + if not case_id: + log.error(f"Failed to get case_id when logging action {action_type} in guild {guild_id}") + return # Don't proceed if we couldn't save the initial log + + # 2. Check settings and send log message + try: + settings = await self.settings_manager.get_guild_settings(guild_id) + log_enabled = settings.get('mod_log_enabled', False) + log_channel_id = settings.get('mod_log_channel_id') + + if not log_enabled or not log_channel_id: + log.debug(f"Mod logging disabled or channel not set for guild {guild_id}. Skipping Discord log message.") + return + + log_channel = guild.get_channel(log_channel_id) + if not log_channel or not isinstance(log_channel, discord.TextChannel): + log.warning(f"Mod log channel {log_channel_id} not found or not a text channel in guild {guild_id}.") + # Optionally update DB to remove channel ID? Or just leave it. + return + + # 3. Format and send embed + embed = self._format_log_embed( + case_id=case_id, + moderator=moderator, # Pass the object for display formatting + target=target, + action_type=action_type, + reason=reason, + duration=duration, + guild=guild, + source=source, + ai_details=ai_details, + moderator_id_override=moderator_id_override # Pass override for formatting + ) + log_message = await log_channel.send(embed=embed) + + # 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) + + 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, + moderator: Union[User, Member], + target: Union[User, Member, Object], + action_type: str, + reason: Optional[str], + duration: Optional[datetime.timedelta], + 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.""" + color_map = { + "BAN": Color.red(), + "UNBAN": Color.green(), + "KICK": Color.orange(), + "TIMEOUT": Color.gold(), + "REMOVE_TIMEOUT": Color.blue(), + "WARN": Color.yellow(), + "AI_ALERT": Color.purple(), + "AI_DELETE_REQUESTED": Color.dark_grey(), + } + 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 = f"{action_title_prefix} | Case #{case_id}" + + embed = Embed( + title=action_title, + color=embed_color, + timestamp=discord.utils.utcnow() + ) + + target_display = f"{getattr(target, 'mention', target.id)} ({target.id})" + + # Determine moderator display based on source + if source == "AI_API": + moderator_display = f"AI System (ID: {moderator_id_override or 'Unknown'})" + else: + 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 '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) + else: + embed.add_field(name="Reason", value=reason or "No reason provided.", inline=False) + else: + embed.add_field(name="Reason", value=reason or "No reason provided.", inline=False) + + 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" + duration_str = duration_str.strip() + + embed.add_field(name="Duration", value=duration_str, inline=True) + # Add expiration timestamp if applicable (e.g., for timeouts) + if action_type.upper() == "TIMEOUT": + expires_at = discord.utils.utcnow() + duration + embed.add_field(name="Expires", value=f"