From 0d5ec7bc0b0743d0b1c2c983edc19c0e90eddbcd Mon Sep 17 00:00:00 2001 From: Slipstream Date: Sat, 3 May 2025 14:47:02 -0600 Subject: [PATCH] a --- dashboard_api/config.py | 60 ----- dashboard_api/main.py | 546 --------------------------------------- dashboard_api/run_api.py | 36 --- dashboard_web/index.html | 71 ----- dashboard_web/script.js | 351 ------------------------- dashboard_web/style.css | 98 ------- 6 files changed, 1162 deletions(-) delete mode 100644 dashboard_api/config.py delete mode 100644 dashboard_api/main.py delete mode 100644 dashboard_api/run_api.py delete mode 100644 dashboard_web/index.html delete mode 100644 dashboard_web/script.js delete mode 100644 dashboard_web/style.css diff --git a/dashboard_api/config.py b/dashboard_api/config.py deleted file mode 100644 index 859be43..0000000 --- a/dashboard_api/config.py +++ /dev/null @@ -1,60 +0,0 @@ -import os -from pydantic_settings import BaseSettings, SettingsConfigDict -from functools import lru_cache - -# Determine the path to the .env file relative to this config file -# Go up one level from dashboard_api/ to the project root where .env should be -dotenv_path = os.path.join(os.path.dirname(__file__), '..', 'discordbot', '.env') - -class Settings(BaseSettings): - # Discord OAuth Credentials - DISCORD_CLIENT_ID: str - DISCORD_CLIENT_SECRET: str - DISCORD_REDIRECT_URI: str # Should match the one set in main.py and Discord Dev Portal - - # Secret key for session management (signing cookies) - # Generate a strong random key for production, e.g., using: - # python -c 'import secrets; print(secrets.token_hex(32))' - DASHBOARD_SECRET_KEY: str = "a_default_secret_key_for_development_only" # Provide a default for dev - - # API settings (optional, if needed) - API_HOST: str = "0.0.0.0" - API_PORT: int = 8000 # Default port for the dashboard API - - # Database/Redis settings - Required for the API to use settings_manager - # These should match the ones used by the bot in discordbot/.env - POSTGRES_USER: str - POSTGRES_PASSWORD: str - POSTGRES_HOST: str - POSTGRES_SETTINGS_DB: str # The specific DB for settings - REDIS_HOST: str - REDIS_PORT: int = 6379 - REDIS_PASSWORD: str | None = None # Optional - - # Pydantic Settings configuration - model_config = SettingsConfigDict( - env_file=dotenv_path, - env_file_encoding='utf-8', - extra='ignore' # Ignore extra fields from .env if any - ) - -# Use lru_cache to load settings only once -@lru_cache() -def get_settings() -> Settings: - # Check if the .env file exists before loading - if not os.path.exists(dotenv_path): - print(f"Warning: .env file not found at {dotenv_path}. Using defaults or environment variables.") - return Settings() - -# Load settings instance -settings = get_settings() - -# --- Constants derived from settings --- -DISCORD_API_BASE_URL = "https://discord.com/api/v10" # Use API v10 -DISCORD_AUTH_URL = ( - f"https://discord.com/api/oauth2/authorize?client_id={settings.DISCORD_CLIENT_ID}" - f"&redirect_uri={settings.DISCORD_REDIRECT_URI}&response_type=code&scope=identify guilds" -) -DISCORD_TOKEN_URL = f"{DISCORD_API_BASE_URL}/oauth2/token" -DISCORD_USER_URL = f"{DISCORD_API_BASE_URL}/users/@me" -DISCORD_USER_GUILDS_URL = f"{DISCORD_API_BASE_URL}/users/@me/guilds" diff --git a/dashboard_api/main.py b/dashboard_api/main.py deleted file mode 100644 index e5db350..0000000 --- a/dashboard_api/main.py +++ /dev/null @@ -1,546 +0,0 @@ -import logging -from fastapi import FastAPI, Request, Depends, HTTPException, status -from fastapi.responses import RedirectResponse, JSONResponse -from starlette.middleware.sessions import SessionMiddleware -import aiohttp -from urllib.parse import quote_plus -import sys -import os -from pydantic import BaseModel, Field -from typing import Dict, List, Optional # For type hinting - -# Ensure discordbot is in path to import settings_manager -# This assumes dashboard_api is run from the project root (z:/projects_git/combined) -# or that the parent directory is in PYTHONPATH. -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) -try: - from discordbot import settings_manager -except ImportError as e: - print(f"ERROR: Could not import discordbot.settings_manager: {e}") - print("Ensure the API is run from the project root or discordbot is in PYTHONPATH.") - settings_manager = None # Set to None to indicate failure - -# Import settings and constants from config.py -from .config import settings, DISCORD_AUTH_URL, DISCORD_TOKEN_URL, DISCORD_USER_URL, DISCORD_USER_GUILDS_URL - -log = logging.getLogger(__name__) -logging.basicConfig(level=logging.INFO) # Basic logging for the API - -# --- FastAPI App Setup --- -app = FastAPI(title="Discord Bot Dashboard API") - -# Add Session Middleware -# IMPORTANT: The secret key *must* be set securely in a real environment. -# It's loaded from config.settings now. -app.add_middleware( - SessionMiddleware, - secret_key=settings.DASHBOARD_SECRET_KEY, - session_cookie="dashboard_session", - max_age=60 * 60 * 24 * 7 # 7 days expiry -) - -# --- Discord API Client --- -# Use a single session for efficiency -http_session = None - -@app.on_event("startup") -async def startup_event(): - """Initialize resources on API startup.""" - global http_session - http_session = aiohttp.ClientSession() - log.info("aiohttp session started.") - # Initialize settings_manager pools if available - if settings_manager: - try: - # Pass config directly if needed, or rely on settings_manager loading its own .env - await settings_manager.initialize_pools() - log.info("Settings manager pools initialized.") - except Exception as e: - log.exception("Failed to initialize settings_manager pools during API startup.") - # Depending on severity, might want to prevent API from starting fully - else: - log.error("settings_manager not imported, database/cache pools NOT initialized for API.") - - -@app.on_event("shutdown") -async def shutdown_event(): - """Clean up resources on API shutdown.""" - if http_session: - await http_session.close() - log.info("aiohttp session closed.") - # Close settings_manager pools if available and initialized - if settings_manager and settings_manager.pg_pool: # Check if pool was initialized - await settings_manager.close_pools() - log.info("Settings manager pools closed.") - -# --- Authentication Routes --- - -@app.get("/api/auth/login", tags=["Authentication"]) -async def login_with_discord(): - """Redirects the user to Discord for OAuth2 authorization.""" - log.info(f"Redirecting user to Discord auth URL: {DISCORD_AUTH_URL}") - return RedirectResponse(url=DISCORD_AUTH_URL, status_code=status.HTTP_307_TEMPORARY_REDIRECT) - -@app.get("/api/auth/callback", tags=["Authentication"]) -async def auth_callback(request: Request, code: str | None = None, error: str | None = None): - """Handles the callback from Discord after authorization.""" - if error: - log.error(f"Discord OAuth error: {error}") - # Redirect to frontend with error message? - return RedirectResponse(url="/?error=discord_auth_failed") # Redirect to frontend root - - if not code: - log.error("Discord OAuth callback missing code.") - return RedirectResponse(url="/?error=missing_code") - - if not http_session: - log.error("aiohttp session not initialized.") - raise HTTPException(status_code=500, detail="Internal server error: HTTP session not ready.") - - try: - # 1. Exchange code for access token - token_data = { - 'client_id': settings.DISCORD_CLIENT_ID, - 'client_secret': settings.DISCORD_CLIENT_SECRET, - 'grant_type': 'authorization_code', - 'code': code, - 'redirect_uri': settings.DISCORD_REDIRECT_URI - } - headers = {'Content-Type': 'application/x-www-form-urlencoded'} - - log.debug(f"Exchanging code for token at {DISCORD_TOKEN_URL}") - async with http_session.post(DISCORD_TOKEN_URL, data=token_data, headers=headers) as resp: - resp.raise_for_status() # Raise exception for bad status codes (4xx or 5xx) - token_response = await resp.json() - access_token = token_response.get('access_token') - refresh_token = token_response.get('refresh_token') # Store this if you need long-term access - expires_in = token_response.get('expires_in') - log.debug("Token exchange successful.") - - if not access_token: - log.error("Failed to get access token from Discord response.") - raise HTTPException(status_code=500, detail="Could not retrieve access token from Discord.") - - # 2. Fetch user data using the access token - user_headers = {'Authorization': f'Bearer {access_token}'} - log.debug(f"Fetching user data from {DISCORD_USER_URL}") - async with http_session.get(DISCORD_USER_URL, headers=user_headers) as resp: - resp.raise_for_status() - user_data = await resp.json() - log.debug(f"User data fetched successfully for user ID: {user_data.get('id')}") - - # 3. Store relevant user data and token in session - request.session['user_id'] = user_data.get('id') - request.session['username'] = user_data.get('username') - request.session['avatar'] = user_data.get('avatar') - request.session['access_token'] = access_token # Store token for API calls - # Optionally store refresh_token and expiry time if needed - - log.info(f"User {user_data.get('username')} ({user_data.get('id')}) logged in successfully.") - # Redirect user back to the main dashboard page - return RedirectResponse(url="/", status_code=status.HTTP_307_TEMPORARY_REDIRECT) # Redirect to frontend root - - except aiohttp.ClientResponseError as e: - log.exception(f"HTTP error during Discord OAuth callback: {e.status} {e.message}") - # Try to get error details from response if possible - error_detail = "Unknown Discord API error" - try: - error_body = await e.response.json() - error_detail = error_body.get("error_description", error_detail) - except Exception: - pass # Ignore if response body isn't JSON or can't be read - raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Error communicating with Discord: {error_detail}") - except Exception as e: - log.exception(f"Generic error during Discord OAuth callback: {e}") - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An internal error occurred during authentication.") - - -@app.post("/api/auth/logout", tags=["Authentication"], status_code=status.HTTP_204_NO_CONTENT) -async def logout(request: Request): - """Clears the user session.""" - user_id = request.session.get('user_id') - request.session.clear() - log.info(f"User {user_id} logged out.") - # No content needed in response, status code 204 indicates success - return - -# --- Authentication Dependency --- -async def get_current_user(request: Request) -> dict: - """Dependency to check if user is authenticated and return user data from session.""" - user_id = request.session.get('user_id') - username = request.session.get('username') - access_token = request.session.get('access_token') # Needed for subsequent Discord API calls - - if not user_id or not username or not access_token: - log.warning("Attempted access by unauthenticated user.") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Not authenticated", - headers={"WWW-Authenticate": "Bearer"}, # Standard header for 401 - ) - # Return essential user info and token for potential use in endpoints - return { - "user_id": user_id, - "username": username, - "avatar": request.session.get('avatar'), - "access_token": access_token - } - -# --- User Endpoints --- -@app.get("/api/user/me", tags=["User"]) -async def get_user_me(current_user: dict = Depends(get_current_user)): - """Returns information about the currently logged-in user.""" - # The dependency already fetched and validated the user data - # We can remove the access token before sending back to frontend if preferred - user_info = current_user.copy() - # del user_info['access_token'] # Optional: Don't expose token to frontend if not needed there - return user_info - -@app.get("/api/user/guilds", tags=["User"]) -async def get_user_guilds(current_user: dict = Depends(get_current_user)): - """Returns a list of guilds the user is an administrator in AND the bot is also in.""" - if not http_session: - log.error("aiohttp session not initialized.") - raise HTTPException(status_code=500, detail="Internal server error: HTTP session not ready.") - if not settings_manager: - log.error("settings_manager not available.") - raise HTTPException(status_code=500, detail="Internal server error: Settings manager not available.") - - access_token = current_user['access_token'] - user_headers = {'Authorization': f'Bearer {access_token}'} - - try: - # 1. Fetch guilds user is in from Discord - log.debug(f"Fetching user guilds from {DISCORD_USER_GUILDS_URL}") - async with http_session.get(DISCORD_USER_GUILDS_URL, headers=user_headers) as resp: - resp.raise_for_status() - user_guilds = await resp.json() - log.debug(f"Fetched {len(user_guilds)} guilds for user {current_user['user_id']}") - - # 2. Fetch guilds the bot is in from our DB - bot_guild_ids = await settings_manager.get_bot_guild_ids() - if bot_guild_ids is None: - log.error("Failed to fetch bot guild IDs from settings_manager.") - raise HTTPException(status_code=500, detail="Could not retrieve bot's guild list.") - - # 3. Filter user guilds - manageable_guilds = [] - ADMINISTRATOR_PERMISSION = 0x8 - for guild in user_guilds: - guild_id = int(guild['id']) - permissions = int(guild['permissions']) - - # Check if user is admin AND bot is in the guild - if (permissions & ADMINISTRATOR_PERMISSION) == ADMINISTRATOR_PERMISSION and guild_id in bot_guild_ids: - manageable_guilds.append({ - "id": guild['id'], - "name": guild['name'], - "icon": guild.get('icon'), # Can be None - # Add other relevant fields if needed - }) - - log.info(f"Found {len(manageable_guilds)} manageable guilds for user {current_user['user_id']}") - return manageable_guilds - - except aiohttp.ClientResponseError as e: - log.exception(f"HTTP error fetching user guilds: {e.status} {e.message}") - if e.status == 401: # Token might have expired - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Discord token invalid or expired. Please re-login.") - raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Error communicating with Discord API.") - except Exception as e: - log.exception(f"Generic error fetching user guilds: {e}") - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An internal error occurred while fetching guilds.") - - -# --- Root endpoint (for basic check and potentially serving frontend) --- -@app.get("/") -async def read_root(): - # This could eventually serve the index.html file - # from fastapi.responses import FileResponse - # return FileResponse('path/to/your/frontend/index.html') - return {"message": "Dashboard API is running - Frontend not served from here yet."} - - -# --- Pydantic Models for Settings --- - -class GuildSettingsResponse(BaseModel): - guild_id: str - prefix: Optional[str] = None - welcome_channel_id: Optional[str] = None - welcome_message: Optional[str] = None - goodbye_channel_id: Optional[str] = None - goodbye_message: Optional[str] = None - enabled_cogs: Dict[str, bool] = {} # Cog name -> enabled status - command_permissions: Dict[str, List[str]] = {} # Command name -> List of allowed role IDs (as strings) - # channels: List[dict] = [] # TODO: Need bot interaction to get this reliably - # roles: List[dict] = [] # TODO: Need bot interaction to get this reliably - -class GuildSettingsUpdate(BaseModel): - # Use Optional fields for PATCH, only provided fields will be updated - prefix: Optional[str] = Field(None, min_length=1, max_length=10) - welcome_channel_id: Optional[str] = Field(None) # Allow empty string or null to disable - welcome_message: Optional[str] = Field(None) - goodbye_channel_id: Optional[str] = Field(None) # Allow empty string or null to disable - goodbye_message: Optional[str] = Field(None) - cogs: Optional[Dict[str, bool]] = Field(None) # Dict of {cog_name: enabled_status} - # command_permissions: Optional[dict] = None # TODO: How to represent updates? Simpler to use dedicated endpoints. - -# --- Command Permission Models --- -class CommandPermission(BaseModel): - command_name: str - role_id: str # Keep as string for consistency - -class CommandPermissionsResponse(BaseModel): - permissions: Dict[str, List[str]] # Command name -> List of allowed role IDs - -# --- Guild Admin Verification Dependency --- - -async def verify_guild_admin(guild_id: int, current_user: dict = Depends(get_current_user)) -> bool: - """Dependency to verify the current user is an admin of the specified guild.""" - if not http_session: - raise HTTPException(status_code=500, detail="Internal server error: HTTP session not ready.") - - user_headers = {'Authorization': f'Bearer {current_user["access_token"]}'} - try: - log.debug(f"Verifying admin status for user {current_user['user_id']} in guild {guild_id}") - async with http_session.get(DISCORD_USER_GUILDS_URL, headers=user_headers) as resp: - if resp.status == 401: - raise HTTPException(status_code=401, detail="Discord token invalid or expired.") - resp.raise_for_status() - user_guilds = await resp.json() - - ADMINISTRATOR_PERMISSION = 0x8 - is_admin = False - for guild in user_guilds: - if int(guild['id']) == guild_id: - permissions = int(guild['permissions']) - if (permissions & ADMINISTRATOR_PERMISSION) == ADMINISTRATOR_PERMISSION: - is_admin = True - break # Found the guild and user is admin - - if not is_admin: - log.warning(f"User {current_user['user_id']} is not admin or not in guild {guild_id}.") - # Use 403 Forbidden if user is authenticated but lacks permissions - # Use 404 Not Found if the guild simply wasn't found in their list (less likely if they selected it) - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is not an administrator of this guild.") - - log.debug(f"User {current_user['user_id']} verified as admin for guild {guild_id}.") - return True # Indicate verification success - - except aiohttp.ClientResponseError as e: - log.exception(f"HTTP error verifying guild admin status: {e.status} {e.message}") - raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Error communicating with Discord API.") - except Exception as e: - log.exception(f"Generic error verifying guild admin status: {e}") - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An internal error occurred during permission verification.") - - -# --- Guild Settings Endpoints --- - -@app.get("/api/guilds/{guild_id}/settings", response_model=GuildSettingsResponse, tags=["Guild Settings"]) -async def get_guild_settings( - guild_id: int, - current_user: dict = Depends(get_current_user), - is_admin: bool = Depends(verify_guild_admin) # Verify admin status first -): - """Fetches the current settings for a specific guild.""" - if not settings_manager: - raise HTTPException(status_code=500, detail="Internal server error: Settings manager not available.") - - log.info(f"Fetching settings for guild {guild_id} requested by user {current_user['user_id']}") - - # Fetch settings using settings_manager - # Note: get_setting returns None if not set, handle "__NONE__" marker if used - prefix = await settings_manager.get_guild_prefix(guild_id, DEFAULT_PREFIX) # Use default from main.py - wc_id = await settings_manager.get_setting(guild_id, 'welcome_channel_id') - wc_msg = await settings_manager.get_setting(guild_id, 'welcome_message') - gc_id = await settings_manager.get_setting(guild_id, 'goodbye_channel_id') - gc_msg = await settings_manager.get_setting(guild_id, 'goodbye_message') - - # Fetch explicitly enabled/disabled cogs status - # This requires knowing the full list of cogs the bot *could* have. - # For now, we only fetch the ones explicitly set in the DB. - # TODO: Get full cog list from bot instance or config? - known_cogs_in_db = {} - try: - async with settings_manager.pg_pool.acquire() as conn: - records = await conn.fetch("SELECT cog_name, enabled FROM enabled_cogs WHERE guild_id = $1", guild_id) - for record in records: - known_cogs_in_db[record['cog_name']] = record['enabled'] - except Exception as e: - log.exception(f"Failed to fetch cog statuses from DB for guild {guild_id}: {e}") - # Return empty dict or raise error? Let's return empty for now. - - # Construct response - settings_data = GuildSettingsResponse( - guild_id=str(guild_id), - prefix=prefix, - welcome_channel_id=wc_id if wc_id != "__NONE__" else None, - welcome_message=wc_msg if wc_msg != "__NONE__" else None, - goodbye_channel_id=gc_id if gc_id != "__NONE__" else None, - goodbye_message=gc_msg if gc_msg != "__NONE__" else None, - enabled_cogs=known_cogs_in_db, - # command_permissions={}, # TODO: Populate this if needed in the main settings GET - # channels=[] # Cannot reliably get channels without bot interaction yet - ) - - return settings_data - - -@app.patch("/api/guilds/{guild_id}/settings", status_code=status.HTTP_200_OK, tags=["Guild Settings"]) -async def update_guild_settings( - guild_id: int, - settings_update: GuildSettingsUpdate, - current_user: dict = Depends(get_current_user), - is_admin: bool = Depends(verify_guild_admin) # Verify admin status -): - """Updates specific settings for a guild.""" - if not settings_manager: - raise HTTPException(status_code=500, detail="Internal server error: Settings manager not available.") - - log.info(f"Updating settings for guild {guild_id} requested by user {current_user['user_id']}") - update_data = settings_update.model_dump(exclude_unset=True) # Get only provided fields - log.debug(f"Update data received: {update_data}") - - success_flags = [] - - # Update prefix if provided - if 'prefix' in update_data: - success = await settings_manager.set_guild_prefix(guild_id, update_data['prefix']) - success_flags.append(success) - if not success: log.error(f"Failed to update prefix for guild {guild_id}") - - # Update welcome settings if provided - if 'welcome_channel_id' in update_data: - # Allow null/empty string to disable - value = update_data['welcome_channel_id'] if update_data['welcome_channel_id'] else None - success = await settings_manager.set_setting(guild_id, 'welcome_channel_id', value) - success_flags.append(success) - if not success: log.error(f"Failed to update welcome_channel_id for guild {guild_id}") - if 'welcome_message' in update_data: - success = await settings_manager.set_setting(guild_id, 'welcome_message', update_data['welcome_message']) - success_flags.append(success) - if not success: log.error(f"Failed to update welcome_message for guild {guild_id}") - - # Update goodbye settings if provided - if 'goodbye_channel_id' in update_data: - value = update_data['goodbye_channel_id'] if update_data['goodbye_channel_id'] else None - success = await settings_manager.set_setting(guild_id, 'goodbye_channel_id', value) - success_flags.append(success) - if not success: log.error(f"Failed to update goodbye_channel_id for guild {guild_id}") - if 'goodbye_message' in update_data: - success = await settings_manager.set_setting(guild_id, 'goodbye_message', update_data['goodbye_message']) - success_flags.append(success) - if not success: log.error(f"Failed to update goodbye_message for guild {guild_id}") - - # Update cog statuses if provided - if 'cogs' in update_data and update_data['cogs'] is not None: - # TODO: Get CORE_COGS list reliably (e.g., from config or bot instance if possible) - core_cogs_list = {'SettingsCog', 'HelpCog'} # Hardcoded for now - for cog_name, enabled_status in update_data['cogs'].items(): - if cog_name not in core_cogs_list: # Prevent changing core cogs - success = await settings_manager.set_cog_enabled(guild_id, cog_name, enabled_status) - success_flags.append(success) - if not success: log.error(f"Failed to update status for cog '{cog_name}' for guild {guild_id}") - else: - log.warning(f"Attempted to change status of core cog '{cog_name}' for guild {guild_id} - ignored.") - - - # Check if all requested updates were successful - if all(success_flags): - return {"message": "Settings updated successfully."} - else: - # Return a partial success or error? For now, generic error if any failed. - raise HTTPException(status_code=500, detail="One or more settings failed to update. Check server logs.") - - -# --- Command Permission Endpoints --- - -@app.get("/api/guilds/{guild_id}/permissions", response_model=CommandPermissionsResponse, tags=["Guild Settings"]) -async def get_all_guild_command_permissions( - guild_id: int, - current_user: dict = Depends(get_current_user), - is_admin: bool = Depends(verify_guild_admin) -): - """Fetches all command permissions currently set for the guild.""" - if not settings_manager: - raise HTTPException(status_code=500, detail="Internal server error: Settings manager not available.") - - log.info(f"Fetching all command permissions for guild {guild_id} requested by user {current_user['user_id']}") - permissions_map: Dict[str, List[str]] = {} - try: - # Fetch all permissions directly from DB for this guild - async with settings_manager.pg_pool.acquire() as conn: - records = await conn.fetch( - "SELECT command_name, allowed_role_id FROM command_permissions WHERE guild_id = $1 ORDER BY command_name, allowed_role_id", - guild_id - ) - for record in records: - cmd = record['command_name'] - role_id_str = str(record['allowed_role_id']) - if cmd not in permissions_map: - permissions_map[cmd] = [] - permissions_map[cmd].append(role_id_str) - - return CommandPermissionsResponse(permissions=permissions_map) - - except Exception as e: - log.exception(f"Database error fetching all command permissions for guild {guild_id}: {e}") - raise HTTPException(status_code=500, detail="Failed to fetch command permissions.") - - -@app.post("/api/guilds/{guild_id}/permissions", status_code=status.HTTP_201_CREATED, tags=["Guild Settings"]) -async def add_guild_command_permission( - guild_id: int, - permission: CommandPermission, - current_user: dict = Depends(get_current_user), - is_admin: bool = Depends(verify_guild_admin) -): - """Adds a role permission for a specific command.""" - if not settings_manager: - raise HTTPException(status_code=500, detail="Internal server error: Settings manager not available.") - - log.info(f"Adding command permission for command '{permission.command_name}', role '{permission.role_id}' in guild {guild_id} requested by user {current_user['user_id']}") - - try: - # Validate role_id format - role_id = int(permission.role_id) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid role_id format. Must be numeric.") - - # TODO: Validate command_name against actual bot commands? Difficult without bot interaction. - - success = await settings_manager.add_command_permission(guild_id, permission.command_name, role_id) - - if success: - # Return the created permission details or just a success message - return {"message": "Permission added successfully.", "command": permission.command_name, "role_id": permission.role_id} - else: - raise HTTPException(status_code=500, detail="Failed to add command permission. Check server logs.") - - -@app.delete("/api/guilds/{guild_id}/permissions", status_code=status.HTTP_200_OK, tags=["Guild Settings"]) -async def remove_guild_command_permission( - guild_id: int, - permission: CommandPermission, # Use the same model for identifying the permission to delete - current_user: dict = Depends(get_current_user), - is_admin: bool = Depends(verify_guild_admin) -): - """Removes a role permission for a specific command.""" - if not settings_manager: - raise HTTPException(status_code=500, detail="Internal server error: Settings manager not available.") - - log.info(f"Removing command permission for command '{permission.command_name}', role '{permission.role_id}' in guild {guild_id} requested by user {current_user['user_id']}") - - try: - role_id = int(permission.role_id) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid role_id format. Must be numeric.") - - success = await settings_manager.remove_command_permission(guild_id, permission.command_name, role_id) - - if success: - return {"message": "Permission removed successfully.", "command": permission.command_name, "role_id": permission.role_id} - else: - # Could be a 404 if permission didn't exist, but 500 is safer if DB fails - raise HTTPException(status_code=500, detail="Failed to remove command permission. Check server logs.") diff --git a/dashboard_api/run_api.py b/dashboard_api/run_api.py deleted file mode 100644 index 4398452..0000000 --- a/dashboard_api/run_api.py +++ /dev/null @@ -1,36 +0,0 @@ -import uvicorn -import os -import sys -from fastapi.staticfiles import StaticFiles - -# Ensure the parent directory (project root) is in the path -# so that 'discordbot' can be imported by the API -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) - -# Import the FastAPI app instance and settings AFTER adjusting path -from dashboard_api.main import app -from dashboard_api.config import settings - -# Mount the static files directory (frontend) -# This assumes the 'dashboard_web' directory is inside 'discordbot' -# Adjust the path if the structure is different. -frontend_path = os.path.join(os.path.dirname(__file__), '..', 'discordbot', 'dashboard_web') - -if os.path.exists(frontend_path): - app.mount("/", StaticFiles(directory=frontend_path, html=True), name="static") - print(f"Serving static files from: {frontend_path}") -else: - print(f"Warning: Frontend directory not found at {frontend_path}. Static file serving disabled.") - - -if __name__ == "__main__": - print(f"Starting Dashboard API server on {settings.API_HOST}:{settings.API_PORT}") - uvicorn.run( - "dashboard_api.main:app", # Reference the app instance correctly - host=settings.API_HOST, - port=settings.API_PORT, - reload=True # Enable auto-reload for development - # Add SSL key/cert paths here if needed for HTTPS directly with uvicorn - # ssl_keyfile=settings.SSL_KEY_FILE, - # ssl_certfile=settings.SSL_CERT_FILE, - ) diff --git a/dashboard_web/index.html b/dashboard_web/index.html deleted file mode 100644 index c0ce557..0000000 --- a/dashboard_web/index.html +++ /dev/null @@ -1,71 +0,0 @@ - - - - - - Bot Dashboard - - - -

Discord Bot Dashboard

- -
- -
- - - - - - diff --git a/dashboard_web/script.js b/dashboard_web/script.js deleted file mode 100644 index 4ef04e7..0000000 --- a/dashboard_web/script.js +++ /dev/null @@ -1,351 +0,0 @@ -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 deleted file mode 100644 index e2fe7ed..0000000 --- a/dashboard_web/style.css +++ /dev/null @@ -1,98 +0,0 @@ -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; -}