From 30f6d93d892d52cb8667b9df21b25a759b4b5f2c Mon Sep 17 00:00:00 2001 From: Slipstream Date: Sat, 3 May 2025 14:47:22 -0600 Subject: [PATCH] a --- api_service/.env.example | 9 + api_service/OAUTH_README.md | 96 ++ api_service/README.md | 153 ++++ api_service/api_models.py | 77 ++ api_service/api_server.py | 1267 ++++++++++++++++++++++++++ api_service/code_verifier_store.py | 25 + api_service/dashboard_web/index.html | 85 ++ api_service/dashboard_web/script.js | 421 +++++++++ api_service/dashboard_web/style.css | 127 +++ api_service/database.py | 204 +++++ api_service/discord_client.py | 207 +++++ api_service/flutter_client.dart | 141 +++ api_service/run_api_server.py | 19 + 13 files changed, 2831 insertions(+) create mode 100644 api_service/.env.example create mode 100644 api_service/OAUTH_README.md create mode 100644 api_service/README.md create mode 100644 api_service/api_models.py create mode 100644 api_service/api_server.py create mode 100644 api_service/code_verifier_store.py create mode 100644 api_service/dashboard_web/index.html create mode 100644 api_service/dashboard_web/script.js create mode 100644 api_service/dashboard_web/style.css create mode 100644 api_service/database.py create mode 100644 api_service/discord_client.py create mode 100644 api_service/flutter_client.dart create mode 100644 api_service/run_api_server.py diff --git a/api_service/.env.example b/api_service/.env.example new file mode 100644 index 0000000..d6bebb7 --- /dev/null +++ b/api_service/.env.example @@ -0,0 +1,9 @@ +# API Server Configuration +API_HOST=0.0.0.0 +API_PORT=443 +DATA_DIR=data + +# Discord OAuth Configuration +DISCORD_CLIENT_ID=1360717457852993576 +# No client secret for public clients +DISCORD_REDIRECT_URI=https://slipstreamm.dev/api/auth diff --git a/api_service/OAUTH_README.md b/api_service/OAUTH_README.md new file mode 100644 index 0000000..765ab38 --- /dev/null +++ b/api_service/OAUTH_README.md @@ -0,0 +1,96 @@ +# OAuth2 Implementation in the API Service + +This document explains how OAuth2 authentication is implemented in the API service. + +## Overview + +The API service now includes a proper OAuth2 implementation that allows users to: + +1. Authenticate with their Discord account +2. Authorize the API service to access Discord resources on their behalf +3. Use the resulting token for API authentication + +This implementation uses the OAuth2 Authorization Code flow with PKCE (Proof Key for Code Exchange) for enhanced security, which is the recommended approach for public clients like mobile apps and Discord bots. + +## How It Works + +### 1. Authorization Flow + +1. The user initiates the OAuth flow by clicking an authorization link (typically from the Discord bot or Flutter app) +2. The user is redirected to Discord's authorization page +3. After authorizing the application, Discord redirects the user to the API service's `/auth` endpoint with an authorization code +4. The API service exchanges the code for an access token +5. The token is stored in the database and associated with the user's Discord ID +6. The user is shown a success page + +### 2. Token Usage + +1. The user includes the access token in the `Authorization` header of API requests +2. The API service verifies the token with Discord +3. If the token is valid, the API service identifies the user and processes the request +4. If the token is invalid, the API service returns a 401 Unauthorized error + +## API Endpoints + +### Authentication + +- `GET /api/auth?code={code}&state={state}` - Handle OAuth callback from Discord +- `GET /api/token` - Get the access token for the authenticated user +- `DELETE /api/token` - Delete the access token for the authenticated user + +## Configuration + +The OAuth implementation requires the following environment variables: + +```env +DISCORD_CLIENT_ID=your_discord_client_id +DISCORD_REDIRECT_URI=https://your-domain.com/api/auth +``` + +Note that we don't use a client secret because this is a public client implementation. Public clients (like mobile apps, single-page applications, or Discord bots) should use PKCE instead of a client secret for security. + +## Security Considerations + +- The API service stores tokens securely in the database +- Tokens are never exposed in logs or error messages +- The API service verifies tokens with Discord for each request +- The API service uses HTTPS to protect tokens in transit + +## Integration with Discord Bot + +The Discord bot can use the API service's OAuth implementation by: + +1. Setting `API_OAUTH_ENABLED=true` in the bot's environment variables +2. Setting `API_URL` to the URL of the API service +3. Using the `!auth` command to initiate the OAuth flow +4. Using the resulting token for API requests + +## Integration with Flutter App + +The Flutter app can use the API service's OAuth implementation by: + +1. Updating the OAuth configuration to use the API service's redirect URI +2. Using the resulting token for API requests + +## Troubleshooting + +### Common Issues + +1. **"Invalid OAuth2 redirect_uri" error** + - Make sure the redirect URI in your Discord application settings matches the one in your environment variables + - The redirect URI should be `https://your-domain.com/api/auth` + +2. **"Invalid client_id" error** + - Make sure the client ID in your environment variables matches the one in your Discord application settings + +3. **"Invalid request" error** + - Make sure you're including the code_verifier parameter when exchanging the authorization code + - The code_verifier must match the one used to generate the code_challenge + +4. **"Invalid code" error** + - The authorization code has expired or has already been used + - Authorization codes are one-time use and expire after a short time + +### Logs + +The API service logs detailed information about the OAuth process. Check the API service's logs for error messages and debugging information. diff --git a/api_service/README.md b/api_service/README.md new file mode 100644 index 0000000..f896e71 --- /dev/null +++ b/api_service/README.md @@ -0,0 +1,153 @@ +# Unified API Service + +This is a centralized API service that both the Discord bot and Flutter app use to store and retrieve data. This ensures consistent data synchronization between both applications. + +## Overview + +The API service provides endpoints for: +- Managing conversations +- Managing user settings +- Authentication via Discord OAuth + +## Setup Instructions + +### 1. Install Dependencies + +```bash +pip install fastapi uvicorn pydantic aiohttp +``` + +### 2. Configure Environment Variables + +Create a `.env` file in the `api_service` directory with the following variables: + +``` +API_HOST=0.0.0.0 +API_PORT=8000 +DATA_DIR=data +``` + +### 3. Start the API Server + +```bash +cd api_service +python api_server.py +``` + +The API server will start on the configured host and port (default: `0.0.0.0:8000`). + +## Discord Bot Integration + +### 1. Update the Discord Bot + +1. Import the API integration in your bot's main file: + +```python +from api_integration import init_api_client + +# Initialize the API client +api_client = init_api_client("https://your-api-url.com/api") +``` + +2. Replace the existing AI cog with the updated version: + +```python +# In your bot.py file +async def setup(bot): + await bot.add_cog(AICog(bot)) +``` + +### 2. Configure Discord OAuth + +1. Go to the [Discord Developer Portal](https://discord.com/developers/applications) +2. Create a new application or use an existing one +3. Go to the OAuth2 section +4. Add a redirect URL: `https://your-api-url.com/api/auth` +5. Copy the Client ID and Client Secret + +## Flutter App Integration + +### 1. Update the Flutter App + +1. Replace the existing `SyncService` with the new `ApiService`: + +```dart +// In your main.dart file +final apiService = ApiService(discordOAuthService); +``` + +2. Update your providers: + +```dart +providers: [ + ChangeNotifierProvider(create: (context) => DiscordOAuthService()), + ChangeNotifierProxyProvider( + create: (context) => ApiService(Provider.of(context, listen: false)), + update: (context, authService, previous) => previous!..update(authService), + ), + ChangeNotifierProxyProvider2( + create: (context) => ChatModel( + Provider.of(context, listen: false), + Provider.of(context, listen: false), + ), + update: (context, openRouterService, apiService, previous) => + previous!..update(openRouterService, apiService), + ), +] +``` + +### 2. Configure Discord OAuth in Flutter + +1. Update the Discord OAuth configuration in your Flutter app: + +```dart +// In discord_oauth_service.dart +const String clientId = 'your-client-id'; +const String redirectUri = 'openroutergui://auth'; +``` + +## API Endpoints + +### Authentication + +- `GET /auth?code={code}&state={state}` - Handle OAuth callback + +### Conversations + +- `GET /conversations` - Get all conversations for the authenticated user +- `GET /conversations/{conversation_id}` - Get a specific conversation +- `POST /conversations` - Create a new conversation +- `PUT /conversations/{conversation_id}` - Update a conversation +- `DELETE /conversations/{conversation_id}` - Delete a conversation + +### Settings + +- `GET /settings` - Get settings for the authenticated user +- `PUT /settings` - Update settings for the authenticated user + +## Security Considerations + +- The API uses Discord OAuth for authentication +- All API requests require a valid Discord token +- The API verifies the token with Discord for each request +- Consider adding rate limiting and additional security measures for production use + +## Troubleshooting + +### API Connection Issues + +- Ensure the API server is running and accessible +- Check that the API URL is correctly configured in both the Discord bot and Flutter app +- Verify that the Discord OAuth credentials are correct + +### Authentication Issues + +- Make sure the Discord OAuth redirect URL is correctly configured +- Check that the client ID and client secret are correct +- Ensure the user has granted the necessary permissions + +### Data Synchronization Issues + +- Check the API server logs for errors +- Verify that both the Discord bot and Flutter app are using the same API URL +- Ensure the user is authenticated in both applications diff --git a/api_service/api_models.py b/api_service/api_models.py new file mode 100644 index 0000000..3dc8cdd --- /dev/null +++ b/api_service/api_models.py @@ -0,0 +1,77 @@ +from typing import Dict, List, Optional, Any, Union +from pydantic import BaseModel, Field +import datetime +import uuid + +# ============= Data Models ============= + +class Message(BaseModel): + content: str + role: str # "user", "assistant", or "system" + timestamp: datetime.datetime + reasoning: Optional[str] = None + usage_data: Optional[Dict[str, Any]] = None + +class Conversation(BaseModel): + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + title: str + messages: List[Message] = [] + created_at: datetime.datetime = Field(default_factory=datetime.datetime.now) + updated_at: datetime.datetime = Field(default_factory=datetime.datetime.now) + + # Conversation-specific settings + model_id: str = "openai/gpt-3.5-turbo" + reasoning_enabled: bool = False + reasoning_effort: str = "medium" # "low", "medium", "high" + temperature: float = 0.7 + max_tokens: int = 1000 + web_search_enabled: bool = False + system_message: Optional[str] = None + +class UserSettings(BaseModel): + # General settings + model_id: str = "openai/gpt-3.5-turbo" + temperature: float = 0.7 + max_tokens: int = 1000 + + # Reasoning settings + reasoning_enabled: bool = False + reasoning_effort: str = "medium" # "low", "medium", "high" + + # Web search settings + web_search_enabled: bool = False + + # System message + system_message: Optional[str] = None + + # Character settings + character: Optional[str] = None + character_info: Optional[str] = None + character_breakdown: bool = False + custom_instructions: Optional[str] = None + + # UI settings + advanced_view_enabled: bool = False + streaming_enabled: bool = True + + # Last updated timestamp + last_updated: datetime.datetime = Field(default_factory=datetime.datetime.now) + +# ============= API Request/Response Models ============= + +class GetConversationsResponse(BaseModel): + conversations: List[Conversation] + +class GetSettingsResponse(BaseModel): + settings: UserSettings + +class UpdateSettingsRequest(BaseModel): + settings: UserSettings + +class UpdateConversationRequest(BaseModel): + conversation: Conversation + +class ApiResponse(BaseModel): + success: bool + message: str + data: Optional[Any] = None diff --git a/api_service/api_server.py b/api_service/api_server.py new file mode 100644 index 0000000..9196945 --- /dev/null +++ b/api_service/api_server.py @@ -0,0 +1,1267 @@ +import os +import json +import asyncio +import datetime +from typing import Dict, List, Optional, Any, Union +from fastapi import FastAPI, HTTPException, Depends, Header, Request, Response +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import RedirectResponse, FileResponse +from fastapi.staticfiles import StaticFiles +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.middleware.sessions import SessionMiddleware # Import SessionMiddleware +import aiohttp +from database import Database # Existing DB +import logging +from pydantic_settings import BaseSettings, SettingsConfigDict +from functools import lru_cache +from typing import Dict, List, Optional, Any, Union # Ensure this is imported + +# --- Configuration Loading --- +# Determine the path to the .env file relative to this api_server.py file +# Go up one level from api_service/ to the project root, then into discordbot/ +dotenv_path = os.path.join(os.path.dirname(__file__), '..', 'discordbot', '.env') + +class ApiSettings(BaseSettings): + # Existing API settings (if any were loaded from env before) + GURT_STATS_PUSH_SECRET: Optional[str] = None + API_HOST: str = "0.0.0.0" # Keep existing default if used + API_PORT: int = 443 # Keep existing default if used + SSL_CERT_FILE: Optional[str] = None + SSL_KEY_FILE: Optional[str] = None + + # Discord OAuth Credentials (from discordbot/.env) + DISCORD_CLIENT_ID: str + DISCORD_CLIENT_SECRET: str + DISCORD_REDIRECT_URI: str + + # Secret key for dashboard session management + DASHBOARD_SECRET_KEY: str = "a_default_secret_key_for_development_only" # Provide a default for dev + + # Database/Redis settings (Required for settings_manager) + 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: Optional[str] = None # Optional + + model_config = SettingsConfigDict( + env_file=dotenv_path, + env_file_encoding='utf-8', + extra='ignore' + ) + +@lru_cache() +def get_api_settings() -> ApiSettings: + if not os.path.exists(dotenv_path): + print(f"Warning: .env file not found at {dotenv_path}. Using defaults or environment variables.") + return ApiSettings() + +settings = get_api_settings() + +# --- Constants derived from settings --- +DISCORD_API_BASE_URL = "https://discord.com/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" + + +# --- Gurt Stats Storage (IPC) --- +latest_gurt_stats: Optional[Dict[str, Any]] = None +# GURT_STATS_PUSH_SECRET is now loaded via ApiSettings +if not settings.GURT_STATS_PUSH_SECRET: + print("Warning: GURT_STATS_PUSH_SECRET not set. Internal stats update endpoint will be insecure.") +# --------------------------------- + +from api_models import ( + Conversation, + UserSettings, + Message, + GetConversationsResponse, + GetSettingsResponse, + UpdateSettingsRequest, + UpdateConversationRequest, + ApiResponse +) +import code_verifier_store + +# Ensure discordbot is in path to import settings_manager +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 + +# ============= API Setup ============= + +# Create the FastAPI app +app = FastAPI(title="Unified API Service") + +# Add Session Middleware for Dashboard Auth +# Uses DASHBOARD_SECRET_KEY from settings +app.add_middleware( + SessionMiddleware, + secret_key=settings.DASHBOARD_SECRET_KEY, + session_cookie="dashboard_session", # Use a distinct cookie name + max_age=60 * 60 * 24 * 7 # 7 days expiry +) + +# Create a sub-application for the API with /api prefix +api_app = FastAPI(title="Unified API Service", docs_url="/docs", openapi_url="/openapi.json") + +# Create a sub-application for backward compatibility with /discordapi prefix +# This will be deprecated in the future +discordapi_app = FastAPI( + title="Discord Bot Sync API (DEPRECATED)", + docs_url="/docs", + openapi_url="/openapi.json", + description="This API is deprecated and will be removed in the future. Please use the /api endpoint instead." +) + +# Create a sub-application for the new Dashboard API +dashboard_api_app = FastAPI( + title="Bot Dashboard API", + docs_url="/docs", # Can have its own docs + openapi_url="/openapi.json" +) + +# Mount the API apps at their respective paths +app.mount("/api", api_app) +app.mount("/discordapi", discordapi_app) +app.mount("/dashboard/api", dashboard_api_app) # Mount the new dashboard API + +# Create a middleware for redirecting /discordapi to /api with a deprecation warning +class DeprecationRedirectMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + # Check if the path starts with /discordapi + if request.url.path.startswith('/discordapi'): + # Add a deprecation warning header + response = await call_next(request) + response.headers['X-API-Deprecation-Warning'] = 'This endpoint is deprecated. Please use /api instead.' + return response + return await call_next(request) + +# Add CORS middleware to all apps +for current_app in [app, api_app, discordapi_app]: + current_app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Adjust this in production + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + +# Add the deprecation middleware to the main app +app.add_middleware(DeprecationRedirectMiddleware) + +# Initialize database (existing) +db = Database() + +# --- aiohttp Session for Discord API calls --- +http_session = None + +# ============= Authentication ============= + +async def verify_discord_token(authorization: str = Header(None)) -> str: + """Verify the Discord token and return the user ID""" + if not authorization: + raise HTTPException(status_code=401, detail="Authorization header missing") + + if not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="Invalid authorization format") + + token = authorization.replace("Bearer ", "") + + # Verify the token with Discord + async with aiohttp.ClientSession() as session: + headers = {"Authorization": f"Bearer {token}"} + async with session.get("https://discord.com/api/v10/users/@me", headers=headers) as resp: + if resp.status != 200: + raise HTTPException(status_code=401, detail="Invalid Discord token") + + user_data = await resp.json() + return user_data["id"] + +# ============= API Endpoints ============= + +@app.get("/") +async def root(): + return {"message": "Unified API Service is running"} + +# Add root for dashboard API for clarity +@dashboard_api_app.get("/") +async def dashboard_api_root(): + return {"message": "Bot Dashboard API is running"} + + +@discordapi_app.get("/") +async def discordapi_root(): + return { + "message": "DEPRECATED: This API endpoint (/discordapi) is deprecated and will be removed in the future.", + "recommendation": "Please update your client to use the /api endpoint instead.", + "new_endpoint": "/api" + } + +# Discord OAuth configuration now loaded via ApiSettings above +# DISCORD_CLIENT_ID = os.getenv("DISCORD_CLIENT_ID", "1360717457852993576") +# DISCORD_REDIRECT_URI = os.getenv("DISCORD_REDIRECT_URI", "https://slipstreamm.dev/api/auth") +# DISCORD_API_ENDPOINT = "https://discord.com/api/v10" +# DISCORD_TOKEN_URL = f"{DISCORD_API_ENDPOINT}/oauth2/token" + +# The existing /auth endpoint seems to handle a different OAuth flow (PKCE, no client secret) +# than the one needed for the dashboard (Authorization Code Grant with client secret). +# We will add the new dashboard auth flow under a different path prefix, e.g., /dashboard/api/auth/... +# Keep the existing /auth endpoint as is for now. + +# @app.get("/auth") # Keep existing +@api_app.get("/auth") +@discordapi_app.get("/auth") +async def auth(code: str, state: str = None, code_verifier: str = None, request: Request = None): + """Handle OAuth callback from Discord""" + try: + # Log the request details for debugging + print(f"Received OAuth callback with code: {code[:10]}...") + print(f"State: {state}") + print(f"Code verifier provided: {code_verifier is not None}") + print(f"Request URL: {request.url if request else 'No request object'}") + print(f"Configured redirect URI: {DISCORD_REDIRECT_URI}") + + # Exchange the code for a token + async with aiohttp.ClientSession() as session: + # For public clients, we don't include a client secret + # We use PKCE for security + # Get the actual redirect URI that Discord used + # This is important because we need to use the same redirect URI when exchanging the code + actual_redirect_uri = DISCORD_REDIRECT_URI + + # If the request has a referer header, use that to extract the redirect URI + referer = request.headers.get("referer") if request else None + if referer and "code=" in referer: + # Extract the redirect URI from the referer + from urllib.parse import urlparse, parse_qs + parsed_url = urlparse(referer) + base_url = f"{parsed_url.scheme}://{parsed_url.netloc}{parsed_url.path}" + print(f"Extracted base URL from referer: {base_url}") + + # Use this as the redirect URI if it's different from the configured one + if base_url != DISCORD_REDIRECT_URI: + print(f"Using redirect URI from referer: {base_url}") + actual_redirect_uri = base_url + + data = { + "client_id": settings.DISCORD_CLIENT_ID, # Use loaded setting + "grant_type": "authorization_code", + "code": code, + "redirect_uri": actual_redirect_uri, + } + + # Add code_verifier if provided directly + if code_verifier: + data["code_verifier"] = code_verifier + print(f"Using provided code_verifier parameter: {code_verifier[:10]}...") + else: + # Try to get the code verifier from the store using the state parameter + stored_code_verifier = code_verifier_store.get_code_verifier(state) if state else None + if stored_code_verifier: + data["code_verifier"] = stored_code_verifier + print(f"Using code_verifier from store for state {state}: {stored_code_verifier[:10]}...") + # Remove the code verifier from the store after using it + code_verifier_store.remove_code_verifier(state) + else: + print(f"Warning: No code_verifier found for state {state}") + + # Log the token exchange request for debugging + print(f"Exchanging code for token with data: {data}") + + async with session.post(DISCORD_TOKEN_URL, data=data) as resp: + if resp.status != 200: + error_text = await resp.text() + print(f"Failed to exchange code: {error_text}") + return {"message": "Authentication failed", "error": error_text} + + token_data = await resp.json() + + # Get the user's information + access_token = token_data.get("access_token") + if not access_token: + return {"message": "Authentication failed", "error": "No access token in response"} + + # Get the user's Discord ID + headers = {"Authorization": f"Bearer {access_token}"} + async with session.get(f"{DISCORD_API_ENDPOINT}/users/@me", headers=headers) as user_resp: + if user_resp.status != 200: + error_text = await user_resp.text() + print(f"Failed to get user info: {error_text}") + return {"message": "Authentication failed", "error": error_text} + + user_data = await user_resp.json() + user_id = user_data.get("id") + + if not user_id: + return {"message": "Authentication failed", "error": "No user ID in response"} + + # Store the token in the database + db.save_user_token(user_id, token_data) + print(f"Successfully authenticated user {user_id} and saved token") + + # Check if this is a programmatic request (from the bot) or a browser request + accept_header = request.headers.get("accept", "") + is_browser = "text/html" in accept_header.lower() + + if is_browser: + # Return a success page with instructions for browser requests + html_content = f""" + + + Authentication Successful + + + +

Authentication Successful!

+

You have successfully authenticated with Discord.

+
+

You can now close this window and return to Discord.

+

Your Discord bot is now authorized to access the API on your behalf.

+
+ + + """ + + return Response(content=html_content, media_type="text/html") + else: + # Return JSON response with token for programmatic requests + return { + "message": "Authentication successful", + "user_id": user_id, + "token": token_data + } + except Exception as e: + print(f"Error in auth endpoint: {str(e)}") + return {"message": "Authentication failed", "error": str(e)} + + +# ============= Dashboard API Models & Dependencies ============= +# (Copied from previous dashboard_api/main.py logic) + +from pydantic import BaseModel, Field # Ensure BaseModel/Field are imported if not already + +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. + +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 + +# --- Authentication Dependency (Dashboard Specific) --- +# Note: This uses session cookies set by the dashboard auth flow +async def get_dashboard_user(request: Request) -> dict: + """Dependency to check if user is authenticated via dashboard session and return user data.""" + 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: + logging.warning("Dashboard: Attempted access by unauthenticated user.") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated for dashboard", + 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 + } + +# --- Guild Admin Verification Dependency (Dashboard Specific) --- +async def verify_dashboard_guild_admin(guild_id: int, current_user: dict = Depends(get_dashboard_user)) -> bool: + """Dependency to verify the dashboard session user is an admin of the specified guild.""" + global http_session # Use the global aiohttp session + 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"Dashboard: 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: + # Clear session if token is invalid + # request.session.clear() # Cannot access request here directly + raise HTTPException(status_code=401, detail="Discord token invalid or expired. Please re-login.") + 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"Dashboard: User {current_user['user_id']} is not admin or not in guild {guild_id}.") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is not an administrator of this guild.") + + log.debug(f"Dashboard: 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"Dashboard: 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"Dashboard: 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.") + + +# ============= Dashboard API Routes ============= +# (Mounted under /dashboard/api) + +# --- Dashboard Authentication Routes --- +@dashboard_api_app.get("/auth/login", tags=["Dashboard Authentication"]) +async def dashboard_login(): + """Redirects the user to Discord for OAuth2 authorization (Dashboard Flow).""" + # Uses constants derived from settings loaded at the top + log.info(f"Dashboard: Redirecting user to Discord auth URL: {DISCORD_AUTH_URL}") + return RedirectResponse(url=DISCORD_AUTH_URL, status_code=status.HTTP_307_TEMPORARY_REDIRECT) + +@dashboard_api_app.get("/auth/callback", tags=["Dashboard Authentication"]) +async def dashboard_auth_callback(request: Request, code: str | None = None, error: str | None = None): + """Handles the callback from Discord after authorization (Dashboard Flow).""" + global http_session # Use the global aiohttp session + if error: + log.error(f"Dashboard: Discord OAuth error: {error}") + return RedirectResponse(url="/dashboard?error=discord_auth_failed") # Redirect to frontend dashboard root + + if not code: + log.error("Dashboard: Discord OAuth callback missing code.") + return RedirectResponse(url="/dashboard?error=missing_code") + + if not http_session: + log.error("Dashboard: 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 # Must match exactly + } + headers = {'Content-Type': 'application/x-www-form-urlencoded'} + + log.debug(f"Dashboard: 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() + token_response = await resp.json() + access_token = token_response.get('access_token') + log.debug("Dashboard: Token exchange successful.") + + if not access_token: + log.error("Dashboard: 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 + user_headers = {'Authorization': f'Bearer {access_token}'} + log.debug(f"Dashboard: 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"Dashboard: User data fetched successfully for user ID: {user_data.get('id')}") + + # 3. Store 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 + + log.info(f"Dashboard: User {user_data.get('username')} ({user_data.get('id')}) logged in successfully.") + # Redirect user back to the main dashboard page (served by static files) + return RedirectResponse(url="/dashboard", status_code=status.HTTP_307_TEMPORARY_REDIRECT) + + except aiohttp.ClientResponseError as e: + log.exception(f"Dashboard: HTTP error during Discord OAuth callback: {e.status} {e.message}") + 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 + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Error communicating with Discord: {error_detail}") + except Exception as e: + log.exception(f"Dashboard: Generic error during Discord OAuth callback: {e}") + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An internal error occurred during authentication.") + +@dashboard_api_app.post("/auth/logout", tags=["Dashboard Authentication"], status_code=status.HTTP_204_NO_CONTENT) +async def dashboard_logout(request: Request): + """Clears the dashboard user session.""" + user_id = request.session.get('user_id') + request.session.clear() + log.info(f"Dashboard: User {user_id} logged out.") + return + +# --- Dashboard User Endpoints --- +@dashboard_api_app.get("/user/me", tags=["Dashboard User"]) +async def dashboard_get_user_me(current_user: dict = Depends(get_dashboard_user)): + """Returns information about the currently logged-in dashboard user.""" + user_info = current_user.copy() + # del user_info['access_token'] # Optional: Don't expose token to frontend + return user_info + +@dashboard_api_app.get("/user/guilds", tags=["Dashboard User"]) +async def dashboard_get_user_guilds(current_user: dict = Depends(get_dashboard_user)): + """Returns a list of guilds the user is an administrator in AND the bot is also in.""" + global http_session # Use the global aiohttp session + if not http_session: + log.error("Dashboard: aiohttp session not initialized.") + raise HTTPException(status_code=500, detail="Internal server error: HTTP session not ready.") + if not settings_manager: + log.error("Dashboard: 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"Dashboard: 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"Dashboard: 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("Dashboard: 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']) + + 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'), + }) + + log.info(f"Dashboard: Found {len(manageable_guilds)} manageable guilds for user {current_user['user_id']}") + return manageable_guilds + + except aiohttp.ClientResponseError as e: + log.exception(f"Dashboard: HTTP error fetching user guilds: {e.status} {e.message}") + if e.status == 401: + 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"Dashboard: Generic error fetching user guilds: {e}") + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An internal error occurred while fetching guilds.") + +# --- Dashboard Guild Settings Endpoints --- +@dashboard_api_app.get("/guilds/{guild_id}/settings", response_model=GuildSettingsResponse, tags=["Dashboard Guild Settings"]) +async def dashboard_get_guild_settings( + guild_id: int, + current_user: dict = Depends(get_dashboard_user), + is_admin: bool = Depends(verify_dashboard_guild_admin) +): + """Fetches the current settings for a specific guild for the dashboard.""" + if not settings_manager: + raise HTTPException(status_code=500, detail="Internal server error: Settings manager not available.") + + log.info(f"Dashboard: Fetching settings for guild {guild_id} requested by user {current_user['user_id']}") + + prefix = await settings_manager.get_guild_prefix(guild_id, "!") # Use default prefix constant + 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') + + known_cogs_in_db = {} + try: + # Need to acquire connection from pool managed by settings_manager + if settings_manager.pg_pool: + 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'] + else: + log.error("Dashboard: settings_manager pg_pool not initialized.") + # Decide how to handle - return empty or error? + except Exception as e: + log.exception(f"Dashboard: Failed to fetch cog statuses from DB for guild {guild_id}: {e}") + + # Fetch command permissions + permissions_map: Dict[str, List[str]] = {} + try: + if settings_manager.pg_pool: + 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) + except Exception as e: + log.exception(f"Dashboard: Failed to fetch command permissions from DB for guild {guild_id}: {e}") + + + 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=permissions_map + ) + return settings_data + +@dashboard_api_app.patch("/guilds/{guild_id}/settings", status_code=status.HTTP_200_OK, tags=["Dashboard Guild Settings"]) +async def dashboard_update_guild_settings( + guild_id: int, + settings_update: GuildSettingsUpdate, + current_user: dict = Depends(get_dashboard_user), + is_admin: bool = Depends(verify_dashboard_guild_admin) +): + """Updates specific settings for a guild via the dashboard.""" + if not settings_manager: + raise HTTPException(status_code=500, detail="Internal server error: Settings manager not available.") + + log.info(f"Dashboard: Updating settings for guild {guild_id} requested by user {current_user['user_id']}") + update_data = settings_update.model_dump(exclude_unset=True) + log.debug(f"Dashboard: Update data received: {update_data}") + + success_flags = [] + core_cogs_list = {'SettingsCog', 'HelpCog'} # TODO: Get this reliably + + 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"Dashboard: Failed to update prefix for guild {guild_id}") + if 'welcome_channel_id' in update_data: + 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"Dashboard: 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"Dashboard: Failed to update welcome_message for guild {guild_id}") + 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"Dashboard: 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"Dashboard: Failed to update goodbye_message for guild {guild_id}") + if 'cogs' in update_data and update_data['cogs'] is not None: + for cog_name, enabled_status in update_data['cogs'].items(): + if cog_name not in core_cogs_list: + success = await settings_manager.set_cog_enabled(guild_id, cog_name, enabled_status) + success_flags.append(success) + if not success: log.error(f"Dashboard: Failed to update status for cog '{cog_name}' for guild {guild_id}") + else: + log.warning(f"Dashboard: Attempted to change status of core cog '{cog_name}' for guild {guild_id} - ignored.") + + if all(s is True for s in success_flags): # Check if all operations returned True + return {"message": "Settings updated successfully."} + else: + raise HTTPException(status_code=500, detail="One or more settings failed to update. Check server logs.") + +# --- Dashboard Command Permission Endpoints --- +@dashboard_api_app.get("/guilds/{guild_id}/permissions", response_model=CommandPermissionsResponse, tags=["Dashboard Guild Settings"]) +async def dashboard_get_all_guild_command_permissions( + guild_id: int, + current_user: dict = Depends(get_dashboard_user), + is_admin: bool = Depends(verify_dashboard_guild_admin) +): + """Fetches all command permissions currently set for the guild for the dashboard.""" + if not settings_manager: + raise HTTPException(status_code=500, detail="Internal server error: Settings manager not available.") + + log.info(f"Dashboard: Fetching all command permissions for guild {guild_id} requested by user {current_user['user_id']}") + permissions_map: Dict[str, List[str]] = {} + try: + if settings_manager.pg_pool: + 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) + else: + log.error("Dashboard: settings_manager pg_pool not initialized.") + + return CommandPermissionsResponse(permissions=permissions_map) + + except Exception as e: + log.exception(f"Dashboard: Database error fetching all command permissions for guild {guild_id}: {e}") + raise HTTPException(status_code=500, detail="Failed to fetch command permissions.") + +@dashboard_api_app.post("/guilds/{guild_id}/permissions", status_code=status.HTTP_201_CREATED, tags=["Dashboard Guild Settings"]) +async def dashboard_add_guild_command_permission( + guild_id: int, + permission: CommandPermission, + current_user: dict = Depends(get_dashboard_user), + is_admin: bool = Depends(verify_dashboard_guild_admin) +): + """Adds a role permission for a specific command via the dashboard.""" + if not settings_manager: + raise HTTPException(status_code=500, detail="Internal server error: Settings manager not available.") + + log.info(f"Dashboard: Adding 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.add_command_permission(guild_id, permission.command_name, role_id) + + if success: + 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.") + +@dashboard_api_app.delete("/guilds/{guild_id}/permissions", status_code=status.HTTP_200_OK, tags=["Dashboard Guild Settings"]) +async def dashboard_remove_guild_command_permission( + guild_id: int, + permission: CommandPermission, + current_user: dict = Depends(get_dashboard_user), + is_admin: bool = Depends(verify_dashboard_guild_admin) +): + """Removes a role permission for a specific command via the dashboard.""" + if not settings_manager: + raise HTTPException(status_code=500, detail="Internal server error: Settings manager not available.") + + log.info(f"Dashboard: 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: + raise HTTPException(status_code=500, detail="Failed to remove command permission. Check server logs.") + + +# ============= Conversation Endpoints ============= +# (Keep existing conversation/settings endpoints under /api and /discordapi) + +@api_app.get("/conversations", response_model=GetConversationsResponse) +@discordapi_app.get("/conversations", response_model=GetConversationsResponse) +async def get_conversations(user_id: str = Depends(verify_discord_token)): + """Get all conversations for a user""" + conversations = db.get_user_conversations(user_id) + return {"conversations": conversations} + +@api_app.get("/conversations/{conversation_id}") +@discordapi_app.get("/conversations/{conversation_id}") +async def get_conversation(conversation_id: str, user_id: str = Depends(verify_discord_token)): + """Get a specific conversation for a user""" + conversation = db.get_conversation(user_id, conversation_id) + if not conversation: + raise HTTPException(status_code=404, detail="Conversation not found") + + return conversation + +@api_app.post("/conversations", response_model=Conversation) +@discordapi_app.post("/conversations", response_model=Conversation) +async def create_conversation( + conversation_request: UpdateConversationRequest, + user_id: str = Depends(verify_discord_token) +): + """Create or update a conversation for a user""" + conversation = conversation_request.conversation + return db.save_conversation(user_id, conversation) + +@api_app.put("/conversations/{conversation_id}", response_model=Conversation) +@discordapi_app.put("/conversations/{conversation_id}", response_model=Conversation) +async def update_conversation( + conversation_id: str, + conversation_request: UpdateConversationRequest, + user_id: str = Depends(verify_discord_token) +): + """Update a specific conversation for a user""" + conversation = conversation_request.conversation + + # Ensure the conversation ID in the path matches the one in the request + if conversation_id != conversation.id: + raise HTTPException(status_code=400, detail="Conversation ID mismatch") + + # Check if the conversation exists + existing_conversation = db.get_conversation(user_id, conversation_id) + if not existing_conversation: + raise HTTPException(status_code=404, detail="Conversation not found") + + return db.save_conversation(user_id, conversation) + +@api_app.delete("/conversations/{conversation_id}", response_model=ApiResponse) +@discordapi_app.delete("/conversations/{conversation_id}", response_model=ApiResponse) +async def delete_conversation( + conversation_id: str, + user_id: str = Depends(verify_discord_token) +): + """Delete a specific conversation for a user""" + success = db.delete_conversation(user_id, conversation_id) + if not success: + raise HTTPException(status_code=404, detail="Conversation not found") + + return {"success": True, "message": "Conversation deleted successfully"} + +# ============= Settings Endpoints ============= + +@api_app.get("/settings") +@discordapi_app.get("/settings") +async def get_settings(user_id: str = Depends(verify_discord_token)): + """Get settings for a user""" + settings = db.get_user_settings(user_id) + # Return both formats for compatibility + return {"settings": settings, "user_settings": settings} + +@api_app.put("/settings", response_model=UserSettings) +@discordapi_app.put("/settings", response_model=UserSettings) +async def update_settings_put( + settings_request: UpdateSettingsRequest, + user_id: str = Depends(verify_discord_token) +): + """Update settings for a user using PUT method""" + settings = settings_request.settings + return db.save_user_settings(user_id, settings) + +@api_app.post("/settings", response_model=UserSettings) +@discordapi_app.post("/settings", response_model=UserSettings) +async def update_settings_post( + request: Request, + user_id: str = Depends(verify_discord_token) +): + """Update settings for a user using POST method (for Flutter app compatibility)""" + try: + # Parse the request body with UTF-8 encoding + body_text = await request.body() + body = json.loads(body_text.decode('utf-8')) + + # Log the received body for debugging + print(f"Received settings POST request with body: {body}") + + # Check if the settings are wrapped in a 'user_settings' field (Flutter app format) + if "user_settings" in body: + settings_data = body["user_settings"] + try: + settings = UserSettings.model_validate(settings_data) + # Save the settings and return the result + result = db.save_user_settings(user_id, settings) + print(f"Saved settings for user {user_id} from 'user_settings' field") + return result + except Exception as e: + print(f"Error validating user_settings: {e}") + # Fall through to try other formats + + # Try standard format with 'settings' field + if "settings" in body: + settings_data = body["settings"] + try: + settings = UserSettings.model_validate(settings_data) + # Save the settings and return the result + result = db.save_user_settings(user_id, settings) + print(f"Saved settings for user {user_id} from 'settings' field") + return result + except Exception as e: + print(f"Error validating settings field: {e}") + # Fall through to try other formats + + # Try direct format (body is the settings object itself) + try: + settings = UserSettings.model_validate(body) + # Save the settings and return the result + result = db.save_user_settings(user_id, settings) + print(f"Saved settings for user {user_id} from direct body") + return result + except Exception as e: + print(f"Error validating direct body: {e}") + # Fall through to final error + + # If we get here, none of the formats worked + raise ValueError("Could not parse settings from any expected format") + except Exception as e: + print(f"Error in update_settings_post: {e}") + raise HTTPException(status_code=400, detail=f"Invalid settings format: {str(e)}") + +# ============= Backward Compatibility Endpoints ============= + +# Define the sync function to be reused by both endpoints +async def _sync_conversations(request: Request, user_id: str): + try: + # Parse the request body with UTF-8 encoding + body_text = await request.body() + body = json.loads(body_text.decode('utf-8')) + + # Log the received body for debugging + print(f"Received sync request with body: {body}") + + # Get conversations from the request + request_conversations = body.get("conversations", []) + + # Get last sync time (for future use with incremental sync) + # Store the last sync time for future use + _ = body.get("last_sync_time") # Currently unused, will be used for incremental sync in the future + + # Get user settings from the request if available + user_settings_data = body.get("user_settings") + if user_settings_data: + # Save user settings + try: + settings = UserSettings.model_validate(user_settings_data) + settings = db.save_user_settings(user_id, settings) + print(f"Saved user settings for {user_id} during sync") + except Exception as e: + print(f"Error saving user settings during sync: {e}") + + # Get all conversations for the user + user_conversations = db.get_user_conversations(user_id) + print(f"Retrieved {len(user_conversations)} conversations for user {user_id}") + + # Process incoming conversations + for conv_data in request_conversations: + try: + conversation = Conversation.model_validate(conv_data) + db.save_conversation(user_id, conversation) + print(f"Saved conversation {conversation.id} for user {user_id}") + except Exception as e: + print(f"Error saving conversation: {e}") + + # Get the user's settings + settings = db.get_user_settings(user_id) + print(f"Retrieved settings for user {user_id}") + + # Return all conversations and settings + response = { + "success": True, + "message": "Sync successful", + "conversations": user_conversations, + } + + # Add settings to the response if available + if settings: + # Include both 'settings' and 'user_settings' for compatibility + response["settings"] = settings + response["user_settings"] = settings + + return response + except Exception as e: + print(f"Sync failed: {str(e)}") + return { + "success": False, + "message": f"Sync failed: {str(e)}", + "conversations": [] + } + +@api_app.post("/sync") +async def api_sync_conversations(request: Request, user_id: str = Depends(verify_discord_token)): + """Sync conversations and settings""" + return await _sync_conversations(request, user_id) + +@discordapi_app.post("/sync") +async def discordapi_sync_conversations(request: Request, user_id: str = Depends(verify_discord_token)): + """Backward compatibility endpoint for syncing conversations""" + response = await _sync_conversations(request, user_id) + # Add deprecation warning to the response + if isinstance(response, dict): + response["deprecated"] = True + response["deprecation_message"] = "This endpoint (/discordapi/sync) is deprecated. Please use /api/sync instead." + return response + +# ============= Server Startup/Shutdown Events ============= + +@app.on_event("startup") +async def startup_event(): + """Initialize resources on API startup.""" + global http_session + # Initialize existing database + db.load_data() + log.info("Existing database loaded.") + # Start aiohttp session + http_session = aiohttp.ClientSession() + log.info("aiohttp session started.") + # Initialize settings_manager pools if available + if settings_manager: + try: + 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.") + 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.""" + global http_session # Ensure http_session is accessible + # Save existing database data + db.save_data() + log.info("Existing database saved.") + # Close aiohttp session + 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.") + + +# ============= Code Verifier Endpoints ============= + +@api_app.post("/code_verifier") +@discordapi_app.post("/code_verifier") +async def store_code_verifier(request: Request): + """Store a code verifier for a state""" + try: + body_text = await request.body() + data = json.loads(body_text.decode('utf-8')) + state = data.get("state") + code_verifier = data.get("code_verifier") + + if not state or not code_verifier: + raise HTTPException(status_code=400, detail="Missing state or code_verifier") + + code_verifier_store.store_code_verifier(state, code_verifier) + return {"success": True, "message": "Code verifier stored successfully"} + except Exception as e: + raise HTTPException(status_code=400, detail=f"Error storing code verifier: {str(e)}") + +@api_app.get("/code_verifier/{state}") +@discordapi_app.get("/code_verifier/{state}") +async def get_code_verifier(state: str): + """Get the code verifier for a state""" + code_verifier = code_verifier_store.get_code_verifier(state) + if not code_verifier: + raise HTTPException(status_code=404, detail="No code verifier found for this state") + + return {"code_verifier": code_verifier} + +# ============= Token Endpoints ============= + +@api_app.get("/token") +@discordapi_app.get("/token") +async def get_token(user_id: str = Depends(verify_discord_token)): + """Get the token for a user""" + token_data = db.get_user_token(user_id) + if not token_data: + raise HTTPException(status_code=404, detail="No token found for this user") + + # 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): + """Get the token for a specific user by ID (for bot use)""" + token_data = db.get_user_token(user_id) + if not token_data: + raise HTTPException(status_code=404, detail="No token found for this user") + + # Return the full token data for the bot to save + return token_data + +@api_app.get("/check_auth/{user_id}") +@discordapi_app.get("/check_auth/{user_id}") +async def check_auth_status(user_id: str): + """Check if a user is authenticated""" + token_data = db.get_user_token(user_id) + if not token_data: + return {"authenticated": False, "message": "User is not authenticated"} + + # Check if the token is valid + try: + access_token = token_data.get("access_token") + if not access_token: + return {"authenticated": False, "message": "No access token found"} + + # Verify the token with Discord + async with aiohttp.ClientSession() as session: + headers = {"Authorization": f"Bearer {access_token}"} + async with session.get(f"{DISCORD_API_ENDPOINT}/users/@me", headers=headers) as resp: + if resp.status != 200: + return {"authenticated": False, "message": "Invalid token"} + + # Token is valid + return {"authenticated": True, "message": "User is authenticated"} + except Exception as e: + print(f"Error checking auth status: {e}") + return {"authenticated": False, "message": f"Error checking auth status: {str(e)}"} + +@api_app.delete("/token") +@discordapi_app.delete("/token") +async def delete_token(user_id: str = Depends(verify_discord_token)): + """Delete the token for a user""" + success = db.delete_user_token(user_id) + if not success: + raise HTTPException(status_code=404, detail="No token found for this user") + + return {"success": True, "message": "Token deleted successfully"} + +@api_app.delete("/token/{user_id}") +@discordapi_app.delete("/token/{user_id}") +async def delete_token_by_user_id(user_id: str): + """Delete the token for a specific user by ID (for bot use)""" + success = db.delete_user_token(user_id) + if not success: + raise HTTPException(status_code=404, detail="No token found for this user") + + return {"success": True, "message": "Token deleted successfully"} + +# ============= Server Shutdown ============= + +@app.on_event("shutdown") +async def shutdown_event(): + """Save all data on shutdown""" + db.save_data() + +# ============= Gurt Stats Endpoints (IPC Approach) ============= + +# --- Internal Endpoint to Receive Stats --- +@app.post("/internal/gurt/update_stats") # Use the main app, not sub-apps +async def update_gurt_stats_internal(request: Request): + """Internal endpoint for the Gurt bot process to push its stats.""" + global latest_gurt_stats + # Basic security check + auth_header = request.headers.get("Authorization") + # Use loaded setting + if not settings.GURT_STATS_PUSH_SECRET or not auth_header or auth_header != f"Bearer {settings.GURT_STATS_PUSH_SECRET}": + print("Unauthorized attempt to update Gurt stats.") + raise HTTPException(status_code=403, detail="Forbidden") + + try: + stats_data = await request.json() + latest_gurt_stats = stats_data + # print(f"Received Gurt stats update at {datetime.datetime.now()}") # Optional: Log successful updates + return {"success": True, "message": "Stats updated"} + except json.JSONDecodeError: + raise HTTPException(status_code=400, detail="Invalid JSON data") + except Exception as e: + print(f"Error processing Gurt stats update: {e}") + raise HTTPException(status_code=500, detail="Error processing stats update") + +# --- Public Endpoint to Get Stats --- +@discordapi_app.get("/gurt/stats") # Add to the deprecated path for now +@api_app.get("/gurt/stats") # Add to the new path as well +async def get_gurt_stats_public(): + """Get latest internal statistics received from the Gurt bot.""" + if latest_gurt_stats is None: + raise HTTPException(status_code=503, detail="Gurt stats not available yet. Please wait for the Gurt bot to send an update.") + return latest_gurt_stats + +# --- Gurt Dashboard Static Files & Route --- +dashboard_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'discordbot', 'gurt_dashboard')) +if os.path.exists(dashboard_dir) and os.path.isdir(dashboard_dir): + # Mount static files (use a unique name like 'gurt_dashboard_static') + # Mount on both /api and /discordapi for consistency during transition + discordapi_app.mount("/gurt/static", StaticFiles(directory=dashboard_dir), name="gurt_dashboard_static_discord") + api_app.mount("/gurt/static", StaticFiles(directory=dashboard_dir), name="gurt_dashboard_static_api") + print(f"Mounted Gurt dashboard static files from: {dashboard_dir}") + + # Route for the main dashboard HTML + @discordapi_app.get("/gurt/dashboard", response_class=FileResponse) # Add to deprecated path + @api_app.get("/gurt/dashboard", response_class=FileResponse) # Add to new path + async def get_gurt_dashboard_combined(): + dashboard_html_path = os.path.join(dashboard_dir, "index.html") + if os.path.exists(dashboard_html_path): + return dashboard_html_path + else: + raise HTTPException(status_code=404, detail="Dashboard index.html not found") +else: + print(f"Warning: Gurt dashboard directory '{dashboard_dir}' not found. Dashboard endpoints will not be available.") + +# --- New Bot Settings Dashboard Static Files & Route --- +new_dashboard_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), 'dashboard_web')) +if os.path.exists(new_dashboard_dir) and os.path.isdir(new_dashboard_dir): + # Mount static files at /dashboard/static (or just /dashboard and rely on html=True) + app.mount("/dashboard", StaticFiles(directory=new_dashboard_dir, html=True), name="bot_dashboard_static") + print(f"Mounted Bot Settings dashboard static files from: {new_dashboard_dir}") + + # Optional: Explicit route for index.html if needed, but html=True should handle it for "/" + # @app.get("/dashboard", response_class=FileResponse) + # async def get_bot_dashboard_index(): + # index_path = os.path.join(new_dashboard_dir, "index.html") + # if os.path.exists(index_path): + # return index_path + # else: + # raise HTTPException(status_code=404, detail="Dashboard index.html not found") +else: + print(f"Warning: Bot Settings dashboard directory '{new_dashboard_dir}' not found. Dashboard will not be available.") + + +# ============= Run the server ============= + +if __name__ == "__main__": + import uvicorn + # Use settings loaded by Pydantic + ssl_available_main = settings.SSL_CERT_FILE and settings.SSL_KEY_FILE and os.path.exists(settings.SSL_CERT_FILE) and os.path.exists(settings.SSL_KEY_FILE) + + uvicorn.run( + "api_server:app", + host=settings.API_HOST, + port=settings.API_PORT, + log_level="info", + ssl_certfile=settings.SSL_CERT_FILE if ssl_available_main else None, + ssl_keyfile=settings.SSL_KEY_FILE if ssl_available_main else None, + ) diff --git a/api_service/code_verifier_store.py b/api_service/code_verifier_store.py new file mode 100644 index 0000000..7c57b71 --- /dev/null +++ b/api_service/code_verifier_store.py @@ -0,0 +1,25 @@ +""" +Code verifier store for the API service. + +This module provides a simple in-memory store for code verifiers used in the OAuth flow. +""" + +from typing import Dict, Optional + +# In-memory storage for code verifiers +code_verifiers: Dict[str, str] = {} + +def store_code_verifier(state: str, code_verifier: str) -> None: + """Store a code verifier for a state.""" + code_verifiers[state] = code_verifier + print(f"Stored code verifier for state {state}: {code_verifier[:10]}...") + +def get_code_verifier(state: str) -> Optional[str]: + """Get the code verifier for a state.""" + return code_verifiers.get(state) + +def remove_code_verifier(state: str) -> None: + """Remove a code verifier for a state.""" + if state in code_verifiers: + del code_verifiers[state] + print(f"Removed code verifier for state {state}") diff --git a/api_service/dashboard_web/index.html b/api_service/dashboard_web/index.html new file mode 100644 index 0000000..bb0a839 --- /dev/null +++ b/api_service/dashboard_web/index.html @@ -0,0 +1,85 @@ + + + + + + Bot Dashboard + + + +

Discord Bot Dashboard

+ +
+ +
+ + + + + + diff --git a/api_service/dashboard_web/script.js b/api_service/dashboard_web/script.js new file mode 100644 index 0000000..ac5fee5 --- /dev/null +++ b/api_service/dashboard_web/script.js @@ -0,0 +1,421 @@ +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' + // IMPORTANT: This will need to be updated to the new merged endpoint prefix, e.g., /dashboard/api + const API_BASE_URL = '/dashboard/api'; // Tentative new prefix + + // --- 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 { + // Use the new endpoint path + 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 + // Use the new endpoint path + window.location.href = `${API_BASE_URL}/auth/login`; + }); + + logoutButton.addEventListener('click', async () => { + try { + // Use the new endpoint path + await fetchAPI('/auth/logout', { method: 'POST' }); + showLogin(); + } catch (error) { + alert('Logout failed. Please try again.'); + } + }); + + // --- Guild Loading and Settings --- + async function loadGuilds() { + try { + // Use the new endpoint path + 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 = ''; + // Changed channel inputs to text + document.getElementById('welcome-channel').value = ''; + document.getElementById('welcome-message').value = ''; + document.getElementById('goodbye-channel').value = ''; + document.getElementById('goodbye-message').value = ''; + document.getElementById('cogs-list').innerHTML = ''; + document.getElementById('current-perms').innerHTML = ''; // Clear permissions list + + try { + // Use the new endpoint path + const settings = await fetchAPI(`/guilds/${guildId}/settings`); + console.log("Received settings:", settings); + + // Populate Prefix + document.getElementById('prefix-input').value = settings.prefix || ''; + + // Populate Welcome/Goodbye Channel IDs + document.getElementById('welcome-channel').value = settings.welcome_channel_id || ''; + document.getElementById('welcome-message').value = settings.welcome_message || ''; + document.getElementById('goodbye-channel').value = settings.goodbye_channel_id || ''; + document.getElementById('goodbye-message').value = settings.goodbye_message || ''; + + // Populate Cogs + // TODO: Need a way to get the *full* list of available cogs from the bot/API + // For now, just display the ones returned by the settings endpoint + populateCogsList(settings.enabled_cogs || {}); + + // Populate Command Permissions + // TODO: Fetch roles and commands for dropdowns + // TODO: Fetch current permissions + await loadCommandPermissions(guildId); + + + } catch (error) { + displayFeedback('prefix-feedback', `Error loading settings: ${error.message}`, true); // Use a general feedback area? + } + } + + 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 + // TODO: Get this list from the API or config + const CORE_COGS = ['SettingsCog', 'HelpCog']; // Example - needs to match backend + + // TODO: Fetch the *full* list of cogs from the bot/API to display all options + // For now, only showing cogs already in the settings response + 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); + }); + } + + async function loadCommandPermissions(guildId) { + const permsDiv = document.getElementById('current-perms'); + permsDiv.innerHTML = 'Loading permissions...'; + try { + // Use the new endpoint path + const permData = await fetchAPI(`/guilds/${guildId}/permissions`); + permsDiv.innerHTML = ''; // Clear loading message + if (Object.keys(permData.permissions).length === 0) { + permsDiv.innerHTML = 'No specific command permissions set. All roles can use all enabled commands (unless restricted by default).'; + return; + } + + // TODO: Fetch role names from Discord API or bot API to display names instead of IDs + for (const [commandName, roleIds] of Object.entries(permData.permissions).sort()) { + const rolesStr = roleIds.map(id => `Role ID: ${id}`).join(', '); // Placeholder until role names are fetched + const div = document.createElement('div'); + div.innerHTML = `Command ${commandName} allowed for: ${rolesStr}`; + permsDiv.appendChild(div); + } + } catch (error) { + permsDiv.innerHTML = `Error loading permissions: ${error.message}`; + } + } + + + // --- 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 { + // Use the new endpoint path + 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; + const channelIdInput = document.getElementById('welcome-channel').value; + 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 { + // Use the new endpoint path + 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 { + // Use the new endpoint path + 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; + const message = document.getElementById('goodbye-message').value; + if (!guildId) return; + + const channelId = channelIdInput && /^\d+$/.test(channelIdInput) ? channelIdInput : null; + + try { + // Use the new endpoint path + 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 { + // Use the new endpoint path + 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 { + // Use the new endpoint path + 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); + } + }); + + // --- Command Permissions Event Listeners --- + document.getElementById('add-perm-button').addEventListener('click', async () => { + const guildId = guildSelect.value; + const commandName = document.getElementById('command-select').value; + const roleId = document.getElementById('role-select').value; + if (!guildId || !commandName || !roleId) { + displayFeedback('perms-feedback', 'Please select a command and a role.', true); + return; + } + + try { + // Use the new endpoint path + await fetchAPI(`/guilds/${guildId}/permissions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ command_name: commandName, role_id: roleId }) + }); + displayFeedback('perms-feedback', `Permission added for ${commandName}.`); + await loadCommandPermissions(guildId); // Refresh list + } catch (error) { + displayFeedback('perms-feedback', `Error adding permission: ${error.message}`, true); + } + }); + + document.getElementById('remove-perm-button').addEventListener('click', async () => { + const guildId = guildSelect.value; + const commandName = document.getElementById('command-select').value; + const roleId = document.getElementById('role-select').value; + if (!guildId || !commandName || !roleId) { + displayFeedback('perms-feedback', 'Please select a command and a role to remove.', true); + return; + } + if (!confirm(`Are you sure you want to remove permission for role ID ${roleId} from command ${commandName}?`)) return; + + try { + // Use the new endpoint path + await fetchAPI(`/guilds/${guildId}/permissions`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ command_name: commandName, role_id: roleId }) + }); + displayFeedback('perms-feedback', `Permission removed for ${commandName}.`); + await loadCommandPermissions(guildId); // Refresh list + } catch (error) { + displayFeedback('perms-feedback', `Error removing permission: ${error.message}`, true); + } + }); + + + // --- Initial Load --- + checkLoginStatus(); +}); diff --git a/api_service/dashboard_web/style.css b/api_service/dashboard_web/style.css new file mode 100644 index 0000000..6135c48 --- /dev/null +++ b/api_service/dashboard_web/style.css @@ -0,0 +1,127 @@ +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; +} + +/* Command Permissions Section */ +#current-perms { + margin-top: 1em; + padding: 0.5em; + border: 1px solid #eee; + max-height: 200px; + overflow-y: auto; +} +#current-perms div { + margin-bottom: 0.3em; + font-size: 0.9em; +} +#current-perms span { + font-weight: bold; +} +#add-perm-button { + background-color: #28a745; /* Green */ +} +#add-perm-button:hover { + background-color: #218838; +} +#remove-perm-button { + background-color: #dc3545; /* Red */ +} +#remove-perm-button:hover { + background-color: #c82333; +} + + +/* 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/api_service/database.py b/api_service/database.py new file mode 100644 index 0000000..eecb839 --- /dev/null +++ b/api_service/database.py @@ -0,0 +1,204 @@ +import os +import json +import datetime +from typing import Dict, List, Optional, Any +from api_models import Conversation, UserSettings, Message + +# ============= Database Class ============= + +class Database: + def __init__(self, data_dir="data"): + self.data_dir = data_dir + self.conversations_file = os.path.join(data_dir, "conversations.json") + self.settings_file = os.path.join(data_dir, "user_settings.json") + self.tokens_file = os.path.join(data_dir, "user_tokens.json") + + # Create data directory if it doesn't exist + os.makedirs(data_dir, exist_ok=True) + + # In-memory storage + self.conversations: Dict[str, Dict[str, Conversation]] = {} # user_id -> conversation_id -> Conversation + self.user_settings: Dict[str, UserSettings] = {} # user_id -> UserSettings + self.user_tokens: Dict[str, Dict[str, Any]] = {} # user_id -> token_data + + # Load data from files + self.load_data() + + def load_data(self): + """Load all data from files""" + self.load_conversations() + self.load_user_settings() + self.load_user_tokens() + + def save_data(self): + """Save all data to files""" + self.save_conversations() + self.save_all_user_settings() + self.save_user_tokens() + + def load_conversations(self): + """Load conversations from file""" + if os.path.exists(self.conversations_file): + try: + with open(self.conversations_file, "r", encoding="utf-8") as f: + data = json.load(f) + # Convert to Conversation objects + self.conversations = { + user_id: { + conv_id: Conversation.model_validate(conv_data) + for conv_id, conv_data in user_convs.items() + } + for user_id, user_convs in data.items() + } + print(f"Loaded conversations for {len(self.conversations)} users") + except Exception as e: + print(f"Error loading conversations: {e}") + self.conversations = {} + + def save_conversations(self): + """Save conversations to file""" + try: + # Convert to JSON-serializable format + serializable_data = { + user_id: { + conv_id: conv.model_dump() + for conv_id, conv in user_convs.items() + } + for user_id, user_convs in self.conversations.items() + } + with open(self.conversations_file, "w", encoding="utf-8") as f: + json.dump(serializable_data, f, indent=2, default=str, ensure_ascii=False) + except Exception as e: + print(f"Error saving conversations: {e}") + + def load_user_settings(self): + """Load user settings from file""" + if os.path.exists(self.settings_file): + try: + with open(self.settings_file, "r", encoding="utf-8") as f: + data = json.load(f) + # Convert to UserSettings objects + self.user_settings = { + user_id: UserSettings.model_validate(settings_data) + for user_id, settings_data in data.items() + } + print(f"Loaded settings for {len(self.user_settings)} users") + except Exception as e: + print(f"Error loading user settings: {e}") + self.user_settings = {} + + def save_all_user_settings(self): + """Save all user settings to file""" + try: + # Convert to JSON-serializable format + serializable_data = { + user_id: settings.model_dump() + for user_id, settings in self.user_settings.items() + } + with open(self.settings_file, "w", encoding="utf-8") as f: + json.dump(serializable_data, f, indent=2, default=str, ensure_ascii=False) + except Exception as e: + print(f"Error saving user settings: {e}") + + # ============= Conversation Methods ============= + + def get_user_conversations(self, user_id: str) -> List[Conversation]: + """Get all conversations for a user""" + return list(self.conversations.get(user_id, {}).values()) + + def get_conversation(self, user_id: str, conversation_id: str) -> Optional[Conversation]: + """Get a specific conversation for a user""" + return self.conversations.get(user_id, {}).get(conversation_id) + + def save_conversation(self, user_id: str, conversation: Conversation) -> Conversation: + """Save a conversation for a user""" + # Update the timestamp + conversation.updated_at = datetime.datetime.now() + + # Initialize user's conversations dict if it doesn't exist + if user_id not in self.conversations: + self.conversations[user_id] = {} + + # Save the conversation + self.conversations[user_id][conversation.id] = conversation + + # Save to disk + self.save_conversations() + + return conversation + + def delete_conversation(self, user_id: str, conversation_id: str) -> bool: + """Delete a conversation for a user""" + if user_id in self.conversations and conversation_id in self.conversations[user_id]: + del self.conversations[user_id][conversation_id] + self.save_conversations() + return True + return False + + # ============= User Settings Methods ============= + + def get_user_settings(self, user_id: str) -> UserSettings: + """Get settings for a user, creating default settings if they don't exist""" + if user_id not in self.user_settings: + self.user_settings[user_id] = UserSettings() + + return self.user_settings[user_id] + + def save_user_settings(self, user_id: str, settings: UserSettings) -> UserSettings: + """Save settings for a user""" + # Update the timestamp + settings.last_updated = datetime.datetime.now() + + # Save the settings + self.user_settings[user_id] = settings + + # Save to disk + self.save_all_user_settings() + + return settings + + # ============= User Tokens Methods ============= + + def load_user_tokens(self): + """Load user tokens from file""" + if os.path.exists(self.tokens_file): + try: + with open(self.tokens_file, "r", encoding="utf-8") as f: + self.user_tokens = json.load(f) + print(f"Loaded tokens for {len(self.user_tokens)} users") + except Exception as e: + print(f"Error loading user tokens: {e}") + self.user_tokens = {} + + def save_user_tokens(self): + """Save user tokens to file""" + try: + with open(self.tokens_file, "w", encoding="utf-8") as f: + json.dump(self.user_tokens, f, indent=2, default=str, ensure_ascii=False) + except Exception as e: + print(f"Error saving user tokens: {e}") + + def get_user_token(self, user_id: str) -> Optional[Dict[str, Any]]: + """Get token data for a user""" + return self.user_tokens.get(user_id) + + def save_user_token(self, user_id: str, token_data: Dict[str, Any]) -> Dict[str, Any]: + """Save token data for a user""" + # Add the time when the token was saved + token_data["saved_at"] = datetime.datetime.now().isoformat() + + # Save the token data + self.user_tokens[user_id] = token_data + + # Save to disk + self.save_user_tokens() + + return token_data + + def delete_user_token(self, user_id: str) -> bool: + """Delete token data for a user""" + if user_id in self.user_tokens: + del self.user_tokens[user_id] + self.save_user_tokens() + return True + return False diff --git a/api_service/discord_client.py b/api_service/discord_client.py new file mode 100644 index 0000000..17862d0 --- /dev/null +++ b/api_service/discord_client.py @@ -0,0 +1,207 @@ +import aiohttp +import json +import datetime +from typing import Dict, List, Optional, Any, Union +from api_models import Conversation, UserSettings, Message + +class ApiClient: + def __init__(self, api_url: str, token: Optional[str] = None): + """ + Initialize the API client + + Args: + api_url: The URL of the API server + token: The Discord token to use for authentication + """ + self.api_url = api_url + self.token = token + + def set_token(self, token: str): + """Set the Discord token for authentication""" + self.token = token + + async def _make_request(self, method: str, endpoint: str, data: Optional[Dict] = None): + """ + Make a request to the API + + Args: + method: The HTTP method to use + endpoint: The API endpoint to call + data: The data to send with the request + + Returns: + The response data + """ + if not self.token: + raise ValueError("No token set for API client") + + headers = { + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json" + } + + url = f"{self.api_url}/{endpoint}" + + async with aiohttp.ClientSession() as session: + if method == "GET": + async with session.get(url, headers=headers) as response: + if response.status != 200: + error_text = await response.text() + raise Exception(f"API request failed: {response.status} - {error_text}") + response_text = await response.text() + return json.loads(response_text) + elif method == "POST": + # Convert data to JSON with datetime handling + json_data = json.dumps(data, default=str, ensure_ascii=False) if data else None + # Update headers for manually serialized JSON + if json_data: + headers["Content-Type"] = "application/json" + async with session.post(url, headers=headers, data=json_data) as response: + if response.status not in (200, 201): + error_text = await response.text() + raise Exception(f"API request failed: {response.status} - {error_text}") + response_text = await response.text() + return json.loads(response_text) + elif method == "PUT": + # Convert data to JSON with datetime handling + json_data = json.dumps(data, default=str, ensure_ascii=False) if data else None + # Update headers for manually serialized JSON + if json_data: + headers["Content-Type"] = "application/json" + async with session.put(url, headers=headers, data=json_data) as response: + if response.status != 200: + error_text = await response.text() + raise Exception(f"API request failed: {response.status} - {error_text}") + response_text = await response.text() + return json.loads(response_text) + elif method == "DELETE": + async with session.delete(url, headers=headers) as response: + if response.status != 200: + error_text = await response.text() + raise Exception(f"API request failed: {response.status} - {error_text}") + response_text = await response.text() + return json.loads(response_text) + else: + raise ValueError(f"Unsupported HTTP method: {method}") + + # ============= Conversation Methods ============= + + async def get_conversations(self) -> List[Conversation]: + """Get all conversations for the authenticated user""" + response = await self._make_request("GET", "conversations") + return [Conversation.model_validate(conv) for conv in response["conversations"]] + + async def get_conversation(self, conversation_id: str) -> Conversation: + """Get a specific conversation""" + response = await self._make_request("GET", f"conversations/{conversation_id}") + return Conversation.model_validate(response) + + async def create_conversation(self, conversation: Conversation) -> Conversation: + """Create a new conversation""" + response = await self._make_request("POST", "conversations", {"conversation": conversation.model_dump()}) + return Conversation.model_validate(response) + + async def update_conversation(self, conversation: Conversation) -> Conversation: + """Update an existing conversation""" + response = await self._make_request("PUT", f"conversations/{conversation.id}", {"conversation": conversation.model_dump()}) + return Conversation.model_validate(response) + + async def delete_conversation(self, conversation_id: str) -> bool: + """Delete a conversation""" + response = await self._make_request("DELETE", f"conversations/{conversation_id}") + return response["success"] + + # ============= Settings Methods ============= + + async def get_settings(self) -> UserSettings: + """Get settings for the authenticated user""" + response = await self._make_request("GET", "settings") + return UserSettings.model_validate(response["settings"]) + + async def update_settings(self, settings: UserSettings) -> UserSettings: + """Update settings for the authenticated user""" + response = await self._make_request("PUT", "settings", {"settings": settings.model_dump()}) + return UserSettings.model_validate(response) + + # ============= Helper Methods ============= + + async def save_discord_conversation( + self, + messages: List[Dict[str, Any]], + model_id: str = "openai/gpt-3.5-turbo", + conversation_id: Optional[str] = None, + title: str = "Discord Conversation", + reasoning_enabled: bool = False, + reasoning_effort: str = "medium", + temperature: float = 0.7, + max_tokens: int = 1000, + web_search_enabled: bool = False, + system_message: Optional[str] = None + ) -> Conversation: + """ + Save a conversation from Discord to the API + + Args: + messages: List of message dictionaries with 'content', 'role', and 'timestamp' + model_id: The model ID to use for the conversation + conversation_id: Optional ID for the conversation (will create new if not provided) + title: The title of the conversation + reasoning_enabled: Whether reasoning is enabled for the conversation + reasoning_effort: The reasoning effort level ("low", "medium", "high") + temperature: The temperature setting for the model + max_tokens: The maximum tokens setting for the model + web_search_enabled: Whether web search is enabled for the conversation + system_message: Optional system message for the conversation + + Returns: + The saved Conversation object + """ + # Convert messages to the API format + api_messages = [] + for msg in messages: + api_messages.append(Message( + content=msg["content"], + role=msg["role"], + timestamp=msg.get("timestamp", datetime.datetime.now()), + reasoning=msg.get("reasoning"), + usage_data=msg.get("usage_data") + )) + + # Create or update the conversation + if conversation_id: + # Try to get the existing conversation + try: + conversation = await self.get_conversation(conversation_id) + # Update the conversation + conversation.messages = api_messages + conversation.model_id = model_id + conversation.reasoning_enabled = reasoning_enabled + conversation.reasoning_effort = reasoning_effort + conversation.temperature = temperature + conversation.max_tokens = max_tokens + conversation.web_search_enabled = web_search_enabled + conversation.system_message = system_message + conversation.updated_at = datetime.datetime.now() + + return await self.update_conversation(conversation) + except Exception: + # Conversation doesn't exist, create a new one + pass + + # Create a new conversation + conversation = Conversation( + id=conversation_id if conversation_id else None, + title=title, + messages=api_messages, + model_id=model_id, + reasoning_enabled=reasoning_enabled, + reasoning_effort=reasoning_effort, + temperature=temperature, + max_tokens=max_tokens, + web_search_enabled=web_search_enabled, + system_message=system_message, + created_at=datetime.datetime.now(), + updated_at=datetime.datetime.now() + ) + + return await self.create_conversation(conversation) diff --git a/api_service/flutter_client.dart b/api_service/flutter_client.dart new file mode 100644 index 0000000..193a67b --- /dev/null +++ b/api_service/flutter_client.dart @@ -0,0 +1,141 @@ +import 'dart:convert'; + +// Note: This is a reference implementation. +// In your actual Flutter app, you would use: +// import 'package:flutter/foundation.dart'; +// import 'package:http/http.dart' as http; + +// Simple HTTP client for reference purposes +class Response { + final int statusCode; + final String body; + + Response(this.statusCode, this.body); +} + +// Simple HTTP methods for reference purposes +class http { + static Future get(Uri url, {Map? headers}) async { + // This is a placeholder. In a real implementation, you would use the http package. + return Future.value(Response(200, '{}')); + } + + static Future post(Uri url, {Map? headers, dynamic body}) async { + // This is a placeholder. In a real implementation, you would use the http package. + return Future.value(Response(200, '{}')); + } + + static Future put(Uri url, {Map? headers, dynamic body}) async { + // This is a placeholder. In a real implementation, you would use the http package. + return Future.value(Response(200, '{}')); + } + + static Future delete(Uri url, {Map? headers}) async { + // This is a placeholder. In a real implementation, you would use the http package. + return Future.value(Response(200, '{}')); + } +} + +/// API client for the unified API service +class ApiClient { + final String apiUrl; + String? _token; + + ApiClient({required this.apiUrl, String? token}) : _token = token; + + /// Set the Discord token for authentication + void setToken(String token) { + _token = token; + } + + /// Get the authorization header for API requests + String? getAuthHeader() { + if (_token == null) return null; + return 'Bearer $_token'; + } + + /// Check if the client is authenticated + bool get isAuthenticated => _token != null; + + /// Make a request to the API + Future _makeRequest(String method, String endpoint, {Map? data}) async { + if (_token == null) { + throw Exception('No token set for API client'); + } + + final headers = {'Authorization': 'Bearer $_token', 'Content-Type': 'application/json'}; + + final url = Uri.parse('$apiUrl/$endpoint'); + Response response; + + try { + if (method == 'GET') { + response = await http.get(url, headers: headers); + } else if (method == 'POST') { + response = await http.post(url, headers: headers, body: data != null ? jsonEncode(data) : null); + } else if (method == 'PUT') { + response = await http.put(url, headers: headers, body: data != null ? jsonEncode(data) : null); + } else if (method == 'DELETE') { + response = await http.delete(url, headers: headers); + } else { + throw Exception('Unsupported HTTP method: $method'); + } + + if (response.statusCode != 200 && response.statusCode != 201) { + throw Exception('API request failed: ${response.statusCode} - ${response.body}'); + } + + return jsonDecode(response.body); + } catch (e) { + print('Error making API request: $e'); + rethrow; + } + } + + // ============= Conversation Methods ============= + + /// Get all conversations for the authenticated user + Future>> getConversations() async { + final response = await _makeRequest('GET', 'conversations'); + return List>.from(response['conversations']); + } + + /// Get a specific conversation + Future> getConversation(String conversationId) async { + final response = await _makeRequest('GET', 'conversations/$conversationId'); + return Map.from(response); + } + + /// Create a new conversation + Future> createConversation(Map conversation) async { + final response = await _makeRequest('POST', 'conversations', data: {'conversation': conversation}); + return Map.from(response); + } + + /// Update an existing conversation + Future> updateConversation(Map conversation) async { + final conversationId = conversation['id']; + final response = await _makeRequest('PUT', 'conversations/$conversationId', data: {'conversation': conversation}); + return Map.from(response); + } + + /// Delete a conversation + Future deleteConversation(String conversationId) async { + final response = await _makeRequest('DELETE', 'conversations/$conversationId'); + return response['success'] as bool; + } + + // ============= Settings Methods ============= + + /// Get settings for the authenticated user + Future> getSettings() async { + final response = await _makeRequest('GET', 'settings'); + return Map.from(response['settings']); + } + + /// Update settings for the authenticated user + Future> updateSettings(Map settings) async { + final response = await _makeRequest('PUT', 'settings', data: {'settings': settings}); + return Map.from(response); + } +} diff --git a/api_service/run_api_server.py b/api_service/run_api_server.py new file mode 100644 index 0000000..9eaa092 --- /dev/null +++ b/api_service/run_api_server.py @@ -0,0 +1,19 @@ +import os +import uvicorn +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +# Get configuration from environment variables +host = os.getenv("API_HOST", "0.0.0.0") +port = int(os.getenv("API_PORT", "8000")) +data_dir = os.getenv("DATA_DIR", "data") + +# Create data directory if it doesn't exist +os.makedirs(data_dir, exist_ok=True) + +if __name__ == "__main__": + print(f"Starting API server on {host}:{port}") + print(f"Data directory: {data_dir}") + uvicorn.run("api_server:app", host=host, port=port, reload=True)