From 05d5394da7d65a6572ae957f294febf772ca5389 Mon Sep 17 00:00:00 2001 From: Slipstream Date: Sat, 3 May 2025 14:28:49 -0600 Subject: [PATCH] aa --- dashboard_web/index.html | 71 ++++++++ dashboard_web/script.js | 351 +++++++++++++++++++++++++++++++++++++++ dashboard_web/style.css | 98 +++++++++++ main.py | 70 +++++++- requirements.txt | 5 +- settings_manager.py | 68 ++++++++ 6 files changed, 659 insertions(+), 4 deletions(-) create mode 100644 dashboard_web/index.html create mode 100644 dashboard_web/script.js create mode 100644 dashboard_web/style.css diff --git a/dashboard_web/index.html b/dashboard_web/index.html new file mode 100644 index 0000000..c0ce557 --- /dev/null +++ b/dashboard_web/index.html @@ -0,0 +1,71 @@ + + + + + + Bot Dashboard + + + +

Discord Bot Dashboard

+ +
+ +
+ + + + + + diff --git a/dashboard_web/script.js b/dashboard_web/script.js new file mode 100644 index 0000000..4ef04e7 --- /dev/null +++ b/dashboard_web/script.js @@ -0,0 +1,351 @@ +document.addEventListener('DOMContentLoaded', () => { + const loginButton = document.getElementById('login-button'); + const logoutButton = document.getElementById('logout-button'); + const authSection = document.getElementById('auth-section'); + const dashboardSection = document.getElementById('dashboard-section'); + const usernameSpan = document.getElementById('username'); + const guildSelect = document.getElementById('guild-select'); + const settingsForm = document.getElementById('settings-form'); + + // --- API Base URL (Adjust if needed) --- + // Assuming the API runs on the same host/port for simplicity, + // otherwise, use the full URL like 'http://localhost:8000' + const API_BASE_URL = '/api'; // Relative path if served by the same server + + // --- Helper Functions --- + async function fetchAPI(endpoint, options = {}) { + // Add authentication headers if needed (e.g., from cookies or localStorage) + // For now, assuming cookies handle session management automatically + try { + const response = await fetch(`${API_BASE_URL}${endpoint}`, options); + if (response.status === 401) { // Unauthorized + showLogin(); + throw new Error('Unauthorized'); + } + if (!response.ok) { + const errorData = await response.json().catch(() => ({ detail: 'Unknown error' })); + throw new Error(errorData.detail || `HTTP error! status: ${response.status}`); + } + if (response.status === 204) { // No Content + return null; + } + return await response.json(); + } catch (error) { + console.error('API Fetch Error:', error); + // Display error to user? + throw error; // Re-throw for specific handlers + } + } + + function showLogin() { + authSection.style.display = 'block'; + dashboardSection.style.display = 'none'; + settingsForm.style.display = 'none'; + guildSelect.value = ''; // Reset guild selection + } + + function showDashboard(userData) { + authSection.style.display = 'none'; + dashboardSection.style.display = 'block'; + usernameSpan.textContent = userData.username; + loadGuilds(); + } + + function displayFeedback(elementId, message, isError = false) { + const feedbackElement = document.getElementById(elementId); + if (feedbackElement) { + feedbackElement.textContent = message; + feedbackElement.className = isError ? 'error' : ''; + // Clear feedback after a few seconds + setTimeout(() => { + feedbackElement.textContent = ''; + feedbackElement.className = ''; + }, 5000); + } + } + + // --- Authentication --- + async function checkLoginStatus() { + try { + const userData = await fetchAPI('/user/me'); + if (userData) { + showDashboard(userData); + } else { + showLogin(); + } + } catch (error) { + // If fetching /user/me fails (e.g., 401), show login + showLogin(); + } + } + + loginButton.addEventListener('click', () => { + // Redirect to backend login endpoint which will redirect to Discord + window.location.href = `${API_BASE_URL}/auth/login`; + }); + + logoutButton.addEventListener('click', async () => { + try { + await fetchAPI('/auth/logout', { method: 'POST' }); + showLogin(); + } catch (error) { + alert('Logout failed. Please try again.'); + } + }); + + // --- Guild Loading and Settings --- + async function loadGuilds() { + try { + const guilds = await fetchAPI('/user/guilds'); + guildSelect.innerHTML = ''; // Reset + guilds.forEach(guild => { + // Only add guilds where the user is an administrator (assuming API filters this) + // Or filter here based on permissions if API doesn't + // const isAdmin = (parseInt(guild.permissions) & 0x8) === 0x8; // Check ADMINISTRATOR bit + // if (isAdmin) { + const option = document.createElement('option'); + option.value = guild.id; + option.textContent = guild.name; + guildSelect.appendChild(option); + // } + }); + } catch (error) { + displayFeedback('guild-select-feedback', `Error loading guilds: ${error.message}`, true); // Add a feedback element if needed + } + } + + guildSelect.addEventListener('change', async (event) => { + const guildId = event.target.value; + if (guildId) { + await loadSettings(guildId); + settingsForm.style.display = 'block'; + } else { + settingsForm.style.display = 'none'; + } + }); + + async function loadSettings(guildId) { + console.log(`Loading settings for guild ${guildId}`); + // Clear previous settings? + document.getElementById('prefix-input').value = ''; + document.getElementById('welcome-channel').innerHTML = ''; + document.getElementById('welcome-message').value = ''; + document.getElementById('goodbye-channel').innerHTML = ''; + document.getElementById('goodbye-message').value = ''; + document.getElementById('cogs-list').innerHTML = ''; + + try { + const settings = await fetchAPI(`/guilds/${guildId}/settings`); + console.log("Received settings:", settings); + + // Populate Prefix + document.getElementById('prefix-input').value = settings.prefix || ''; + + // Populate Welcome/Goodbye IDs (Dropdown population is not feasible from API alone) + // We'll just display the ID if set, or allow input? Let's stick to the select for now, + // but it won't be populated dynamically. The user needs to know the channel ID. + // We can pre-select the stored value if it exists. + const wcSelect = document.getElementById('welcome-channel'); + wcSelect.innerHTML = ''; // Clear previous options + if (settings.welcome_channel_id) { + // Add the stored ID as an option, maybe mark it as potentially invalid if needed + const option = document.createElement('option'); + option.value = settings.welcome_channel_id; + option.textContent = `#? (ID: ${settings.welcome_channel_id})`; // Indicate it's just the ID + option.selected = true; + wcSelect.appendChild(option); + } + document.getElementById('welcome-message').value = settings.welcome_message || ''; + + const gcSelect = document.getElementById('goodbye-channel'); + gcSelect.innerHTML = ''; // Clear previous options + if (settings.goodbye_channel_id) { + const option = document.createElement('option'); + option.value = settings.goodbye_channel_id; + option.textContent = `#? (ID: ${settings.goodbye_channel_id})`; + option.selected = true; + gcSelect.appendChild(option); + } + document.getElementById('goodbye-message').value = settings.goodbye_message || ''; + + // Populate Cogs - This will only show cogs whose state is known by the API/DB + // It won't show all possible cogs unless the API is enhanced. + populateCogsList(settings.enabled_cogs || {}); // Use the correct field name + + } catch (error) { + displayFeedback('prefix-feedback', `Error loading settings: ${error.message}`, true); // Use a general feedback area? + } + } + + // Removed populateChannelSelect as dynamic population isn't feasible from API alone. + // Users will need to manage channel IDs directly for now. + + function populateCogsList(cogsStatus) { + // This function now only displays cogs whose status is stored in the DB + // and returned by the API. It doesn't know about *all* possible cogs. + const cogsListDiv = document.getElementById('cogs-list'); + cogsListDiv.innerHTML = ''; // Clear previous + // Assuming CORE_COGS is available globally or passed somehow + const CORE_COGS = ['SettingsCog', 'HelpCog']; // Example - needs to match backend + + Object.entries(cogsStatus).sort().forEach(([cogName, isEnabled]) => { + const div = document.createElement('div'); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.id = `cog-${cogName}`; + checkbox.name = cogName; + checkbox.checked = isEnabled; + checkbox.disabled = CORE_COGS.includes(cogName); // Disable core cogs + + const label = document.createElement('label'); + label.htmlFor = `cog-${cogName}`; + label.textContent = cogName + (CORE_COGS.includes(cogName) ? ' (Core)' : ''); + + div.appendChild(checkbox); + div.appendChild(label); + cogsListDiv.appendChild(div); + }); + } + + + // --- Save Settings Event Listeners --- + + document.getElementById('save-prefix-button').addEventListener('click', async () => { + const guildId = guildSelect.value; + const prefix = document.getElementById('prefix-input').value; + if (!guildId) return; + + try { + await fetchAPI(`/guilds/${guildId}/settings`, { + method: 'PATCH', // Use PATCH for partial updates + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prefix: prefix }) + }); + displayFeedback('prefix-feedback', 'Prefix saved successfully!'); + } catch (error) { + displayFeedback('prefix-feedback', `Error saving prefix: ${error.message}`, true); + } + }); + + document.getElementById('save-welcome-button').addEventListener('click', async () => { + const guildId = guildSelect.value; + // Get channel ID directly. Assume user inputs/knows the ID. + // We might change the input type from select later if this is confusing. + const channelIdInput = document.getElementById('welcome-channel').value; // Treat select as input for now + const message = document.getElementById('welcome-message').value; + if (!guildId) return; + + // Basic validation for channel ID (numeric) + const channelId = channelIdInput && /^\d+$/.test(channelIdInput) ? channelIdInput : null; + + try { + await fetchAPI(`/guilds/${guildId}/settings`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + welcome_channel_id: channelId, // Send numeric ID or null + welcome_message: message + }) + }); + displayFeedback('welcome-feedback', 'Welcome settings saved!'); + } catch (error) { + displayFeedback('welcome-feedback', `Error saving welcome settings: ${error.message}`, true); + } + }); + + document.getElementById('disable-welcome-button').addEventListener('click', async () => { + const guildId = guildSelect.value; + if (!guildId) return; + if (!confirm('Are you sure you want to disable welcome messages?')) return; + + try { + await fetchAPI(`/guilds/${guildId}/settings`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + welcome_channel_id: null, + welcome_message: null // Also clear message template maybe? Or just channel? Let's clear both. + }) + }); + // Clear the form fields visually + document.getElementById('welcome-channel').value = ''; + document.getElementById('welcome-message').value = ''; + displayFeedback('welcome-feedback', 'Welcome messages disabled.'); + } catch (error) { + displayFeedback('welcome-feedback', `Error disabling welcome messages: ${error.message}`, true); + } + }); + + document.getElementById('save-goodbye-button').addEventListener('click', async () => { + const guildId = guildSelect.value; + const channelIdInput = document.getElementById('goodbye-channel').value; // Treat select as input + const message = document.getElementById('goodbye-message').value; + if (!guildId) return; + + const channelId = channelIdInput && /^\d+$/.test(channelIdInput) ? channelIdInput : null; + + try { + await fetchAPI(`/guilds/${guildId}/settings`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + goodbye_channel_id: channelId, + goodbye_message: message + }) + }); + displayFeedback('goodbye-feedback', 'Goodbye settings saved!'); + } catch (error) { + displayFeedback('goodbye-feedback', `Error saving goodbye settings: ${error.message}`, true); + } + }); + + document.getElementById('disable-goodbye-button').addEventListener('click', async () => { + const guildId = guildSelect.value; + if (!guildId) return; + if (!confirm('Are you sure you want to disable goodbye messages?')) return; + + try { + await fetchAPI(`/guilds/${guildId}/settings`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + goodbye_channel_id: null, + goodbye_message: null + }) + }); + document.getElementById('goodbye-channel').value = ''; + document.getElementById('goodbye-message').value = ''; + displayFeedback('goodbye-feedback', 'Goodbye messages disabled.'); + } catch (error) { + displayFeedback('goodbye-feedback', `Error disabling goodbye messages: ${error.message}`, true); + } + }); + + document.getElementById('save-cogs-button').addEventListener('click', async () => { + const guildId = guildSelect.value; + if (!guildId) return; + + const cogsPayload = {}; + const checkboxes = document.querySelectorAll('#cogs-list input[type="checkbox"]'); + checkboxes.forEach(cb => { + if (!cb.disabled) { // Don't send status for disabled (core) cogs + cogsPayload[cb.name] = cb.checked; + } + }); + + try { + await fetchAPI(`/guilds/${guildId}/settings`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cogs: cogsPayload }) + }); + displayFeedback('cogs-feedback', 'Module settings saved!'); + } catch (error) { + displayFeedback('cogs-feedback', `Error saving module settings: ${error.message}`, true); + } + }); + + + // --- Initial Load --- + checkLoginStatus(); +}); diff --git a/dashboard_web/style.css b/dashboard_web/style.css new file mode 100644 index 0000000..e2fe7ed --- /dev/null +++ b/dashboard_web/style.css @@ -0,0 +1,98 @@ +body { + font-family: sans-serif; + margin: 2em; + background-color: #f4f4f4; +} + +h1, h2, h3, h4 { + color: #333; +} + +#dashboard-section, #settings-form { + background-color: #fff; + padding: 1.5em; + border-radius: 8px; + margin-top: 1em; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +label { + display: block; + margin-top: 1em; + margin-bottom: 0.5em; + font-weight: bold; +} + +input[type="text"], +select, +textarea { + width: 95%; + padding: 8px; + margin-bottom: 1em; + border: 1px solid #ccc; + border-radius: 4px; +} + +textarea { + resize: vertical; +} + +button { + padding: 10px 15px; + background-color: #5865F2; /* Discord blue */ + color: white; + border: none; + border-radius: 5px; + cursor: pointer; + margin-right: 5px; + margin-top: 5px; +} + +button:hover { + background-color: #4752C4; +} + +#logout-button { + background-color: #dc3545; /* Red */ +} +#logout-button:hover { + background-color: #c82333; +} + +button[id^="disable-"] { + background-color: #ffc107; /* Yellow/Orange */ + color: #333; +} +button[id^="disable-"]:hover { + background-color: #e0a800; +} + + +hr { + border: 0; + height: 1px; + background: #ddd; + margin: 2em 0; +} + +#cogs-list div { + margin-bottom: 0.5em; +} + +#cogs-list label { + display: inline-block; + margin-left: 5px; + font-weight: normal; +} + +/* Feedback messages */ +p[id$="-feedback"] { + font-style: italic; + color: green; + margin-top: 5px; + min-height: 1em; /* Reserve space */ +} + +p[id$="-feedback"].error { + color: red; +} diff --git a/main.py b/main.py index 915200c..a67757a 100644 --- a/main.py +++ b/main.py @@ -65,11 +65,45 @@ log = logging.getLogger(__name__) # Logger for main.py # --- Events --- @bot.event async def on_ready(): - print(f'{bot.user.name} has connected to Discord!') - print(f'Bot ID: {bot.user.id}') + log.info(f'{bot.user.name} has connected to Discord!') + log.info(f'Bot ID: {bot.user.id}') # Set the bot's status await bot.change_presence(activity=discord.Activity(type=discord.ActivityType.listening, name="!help")) - print("Bot status set to 'Listening to !help'") + log.info("Bot status set to 'Listening to !help'") + + # --- Add current guilds to DB --- + if settings_manager and settings_manager.pg_pool: + log.info("Syncing guilds with database...") + try: + async with settings_manager.pg_pool.acquire() as conn: + # Get guilds bot is currently in + current_guild_ids = {guild.id for guild in bot.guilds} + log.debug(f"Bot is currently in {len(current_guild_ids)} guilds.") + + # Get guilds currently in DB + db_records = await conn.fetch("SELECT guild_id FROM guilds") + db_guild_ids = {record['guild_id'] for record in db_records} + log.debug(f"Found {len(db_guild_ids)} guilds in database.") + + # Add guilds bot joined while offline + guilds_to_add = current_guild_ids - db_guild_ids + if guilds_to_add: + log.info(f"Adding {len(guilds_to_add)} new guilds to database: {guilds_to_add}") + await conn.executemany("INSERT INTO guilds (guild_id) VALUES ($1) ON CONFLICT DO NOTHING;", + [(guild_id,) for guild_id in guilds_to_add]) + + # Remove guilds bot left while offline + guilds_to_remove = db_guild_ids - current_guild_ids + if guilds_to_remove: + log.info(f"Removing {len(guilds_to_remove)} guilds from database: {guilds_to_remove}") + await conn.execute("DELETE FROM guilds WHERE guild_id = ANY($1::bigint[])", list(guilds_to_remove)) + + log.info("Guild sync with database complete.") + except Exception as e: + log.exception("Error syncing guilds with database on ready.") + else: + log.warning("Settings manager not available or pool not initialized, skipping guild sync.") + # ----------------------------- # Patch Discord methods to store message content try: @@ -112,6 +146,36 @@ async def on_shard_disconnect(shard_id): except Exception as e: print(f"Failed to reconnect shard {shard_id}: {e}") +@bot.event +async def on_guild_join(guild: discord.Guild): + """Adds guild to database when bot joins.""" + log.info(f"Joined guild: {guild.name} ({guild.id})") + if settings_manager and settings_manager.pg_pool: + try: + async with settings_manager.pg_pool.acquire() as conn: + await conn.execute("INSERT INTO guilds (guild_id) VALUES ($1) ON CONFLICT DO NOTHING;", guild.id) + log.info(f"Added guild {guild.id} to database.") + except Exception as e: + log.exception(f"Failed to add guild {guild.id} to database on join.") + else: + log.warning("Settings manager not available or pool not initialized, cannot add guild on join.") + +@bot.event +async def on_guild_remove(guild: discord.Guild): + """Removes guild from database when bot leaves.""" + log.info(f"Left guild: {guild.name} ({guild.id})") + if settings_manager and settings_manager.pg_pool: + try: + async with settings_manager.pg_pool.acquire() as conn: + # Note: Cascading deletes should handle related settings in other tables + await conn.execute("DELETE FROM guilds WHERE guild_id = $1", guild.id) + log.info(f"Removed guild {guild.id} from database.") + except Exception as e: + log.exception(f"Failed to remove guild {guild.id} from database on leave.") + else: + log.warning("Settings manager not available or pool not initialized, cannot remove guild on leave.") + + # Error handling - Updated to handle custom check failures @bot.event async def on_command_error(ctx, error): diff --git a/requirements.txt b/requirements.txt index dcc9a98..4ff7331 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,8 +12,11 @@ google-cloud-storage beautifulsoup4 requests fastapi -uvicorn +uvicorn[standard] # Use standard install for performance features like websockets if needed later python-multipart +python-jose[cryptography] # For JWT handling in auth +itsdangerous # For secure session cookies +pydantic-settings # For loading config from .env tenacity cachetools pillow diff --git a/settings_manager.py b/settings_manager.py index ad4ed0d..98e506b 100644 --- a/settings_manager.py +++ b/settings_manager.py @@ -494,3 +494,71 @@ async def check_command_permission(guild_id: int, command_name: str, member_role else: log.debug(f"Permission denied for '{command_name}' (Guild: {guild_id}). Member roles {member_roles_ids_str} not in allowed roles {allowed_role_ids_str}.") return False # Member has none of the specifically allowed roles + + +async def get_command_permissions(guild_id: int, command_name: str) -> set[int] | None: + """Gets the set of allowed role IDs for a specific command, checking cache first. Returns None on error.""" + if not pg_pool or not redis_pool: + log.warning(f"Pools not initialized, cannot get permissions for command '{command_name}'.") + return None + + cache_key = _get_redis_key(guild_id, "cmd_perms", command_name) + try: + # Check cache first + if await redis_pool.exists(cache_key): + cached_roles_str = await redis_pool.smembers(cache_key) + if cached_roles_str == {"__EMPTY_SET__"}: + log.debug(f"Cache hit (empty set) for cmd perms '{command_name}' (Guild: {guild_id}).") + return set() # Return empty set if explicitly empty + allowed_role_ids = {int(role_id) for role_id in cached_roles_str} + log.debug(f"Cache hit for cmd perms '{command_name}' (Guild: {guild_id})") + return allowed_role_ids + except Exception as e: + log.exception(f"Redis error getting cmd perms for '{command_name}' (Guild: {guild_id}): {e}") + # Fall through to DB query on Redis error + + log.debug(f"Cache miss for cmd perms '{command_name}' (Guild: {guild_id})") + try: + async with pg_pool.acquire() as conn: + records = await conn.fetch( + "SELECT allowed_role_id FROM command_permissions WHERE guild_id = $1 AND command_name = $2", + guild_id, command_name + ) + allowed_role_ids = {record['allowed_role_id'] for record in records} + + # Cache the result + try: + allowed_role_ids_str = {str(role_id) for role_id in allowed_role_ids} + async with redis_pool.pipeline(transaction=True) as pipe: + pipe.delete(cache_key) # Ensure clean state + if allowed_role_ids_str: + pipe.sadd(cache_key, *allowed_role_ids_str) + else: + pipe.sadd(cache_key, "__EMPTY_SET__") # Marker for empty set + pipe.expire(cache_key, 3600) # Cache for 1 hour + await pipe.execute() + except Exception as e: + log.exception(f"Redis error setting cache for cmd perms '{command_name}' (Guild: {guild_id}): {e}") + + return allowed_role_ids + except Exception as e: + log.exception(f"Database error getting cmd perms for '{command_name}' (Guild: {guild_id}): {e}") + return None # Indicate error + + +# --- Bot Guild Information --- + +async def get_bot_guild_ids() -> set[int] | None: + """Gets the set of all guild IDs known to the bot from the guilds table. Returns None on error.""" + if not pg_pool: + log.error("Pools not initialized, cannot get bot guild IDs.") + return None + try: + async with pg_pool.acquire() as conn: + records = await conn.fetch("SELECT guild_id FROM guilds") + guild_ids = {record['guild_id'] for record in records} + log.debug(f"Fetched {len(guild_ids)} guild IDs from database.") + return guild_ids + except Exception as e: + log.exception("Database error fetching bot guild IDs.") + return None