This commit is contained in:
Slipstream 2025-05-03 14:47:22 -06:00
parent 0d5ec7bc0b
commit 30f6d93d89
Signed by: slipstream
GPG Key ID: 13E498CE010AC6FD
13 changed files with 2831 additions and 0 deletions

9
api_service/.env.example Normal file
View File

@ -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

View File

@ -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.

153
api_service/README.md Normal file
View File

@ -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<DiscordOAuthService, ApiService>(
create: (context) => ApiService(Provider.of<DiscordOAuthService>(context, listen: false)),
update: (context, authService, previous) => previous!..update(authService),
),
ChangeNotifierProxyProvider2<OpenRouterService, ApiService, ChatModel>(
create: (context) => ChatModel(
Provider.of<OpenRouterService>(context, listen: false),
Provider.of<ApiService>(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

77
api_service/api_models.py Normal file
View File

@ -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

1267
api_service/api_server.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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}")

View File

@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bot Dashboard</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>Discord Bot Dashboard</h1>
<div id="auth-section">
<button id="login-button">Login with Discord</button>
</div>
<div id="dashboard-section" style="display: none;">
<h2>Welcome, <span id="username">User</span>!</h2>
<button id="logout-button">Logout</button>
<hr>
<h3>Manage Server Settings</h3>
<label for="guild-select">Select Server:</label>
<select name="guilds" id="guild-select">
<option value="">--Please choose a server--</option>
</select>
<div id="settings-form" style="display: none;">
<h4>Prefix</h4>
<label for="prefix-input">Command Prefix:</label>
<input type="text" id="prefix-input" name="prefix" maxlength="10">
<button id="save-prefix-button">Save Prefix</button>
<p id="prefix-feedback"></p>
<h4>Welcome Messages</h4>
<label for="welcome-channel">Welcome Channel ID:</label> <!-- Changed label -->
<input type="text" id="welcome-channel" name="welcome_channel_id" placeholder="Enter Channel ID"> <!-- Changed to text input -->
<br>
<label for="welcome-message">Welcome Message Template:</label><br>
<textarea id="welcome-message" name="welcome_message" rows="4" cols="50" placeholder="Use {user} for mention, {username} for name, {server} for server name."></textarea><br>
<button id="save-welcome-button">Save Welcome Settings</button>
<button id="disable-welcome-button">Disable Welcome</button>
<p id="welcome-feedback"></p>
<h4>Goodbye Messages</h4>
<label for="goodbye-channel">Goodbye Channel ID:</label> <!-- Changed label -->
<input type="text" id="goodbye-channel" name="goodbye_channel_id" placeholder="Enter Channel ID"> <!-- Changed to text input -->
<br>
<label for="goodbye-message">Goodbye Message Template:</label><br>
<textarea id="goodbye-message" name="goodbye_message" rows="4" cols="50" placeholder="Use {username} for name, {server} for server name."></textarea><br>
<button id="save-goodbye-button">Save Goodbye Settings</button>
<button id="disable-goodbye-button">Disable Goodbye</button>
<p id="goodbye-feedback"></p>
<h4>Enabled Modules (Cogs)</h4>
<div id="cogs-list">
<!-- Cog checkboxes will be populated by JS -->
</div>
<button id="save-cogs-button">Save Module Settings</button>
<p id="cogs-feedback"></p>
<h4>Command Permissions</h4>
<label for="command-select">Command:</label>
<select id="command-select">
<!-- TODO: Populate commands dynamically -->
<option value="">-- Select Command --</option>
</select>
<label for="role-select">Role:</label>
<select id="role-select">
<!-- TODO: Populate roles dynamically -->
<option value="">-- Select Role --</option>
</select>
<button id="add-perm-button">Allow Role</button>
<button id="remove-perm-button">Disallow Role</button>
<div id="current-perms">
<!-- Current permissions will be listed here -->
</div>
<p id="perms-feedback"></p>
</div>
</div>
<script src="script.js"></script>
</body>
</html>

View File

@ -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 = '<option value="">--Please choose a server--</option>'; // 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 = '<i>No specific command permissions set. All roles can use all enabled commands (unless restricted by default).</i>';
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 <span>${commandName}</span> allowed for: ${rolesStr}`;
permsDiv.appendChild(div);
}
} catch (error) {
permsDiv.innerHTML = `<i class="error">Error loading permissions: ${error.message}</i>`;
}
}
// --- 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();
});

View File

@ -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;
}

204
api_service/database.py Normal file
View File

@ -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

View File

@ -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)

View File

@ -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<Response> get(Uri url, {Map<String, String>? headers}) async {
// This is a placeholder. In a real implementation, you would use the http package.
return Future.value(Response(200, '{}'));
}
static Future<Response> post(Uri url, {Map<String, String>? 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<Response> put(Uri url, {Map<String, String>? 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<Response> delete(Uri url, {Map<String, String>? 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<dynamic> _makeRequest(String method, String endpoint, {Map<String, dynamic>? 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<List<Map<String, dynamic>>> getConversations() async {
final response = await _makeRequest('GET', 'conversations');
return List<Map<String, dynamic>>.from(response['conversations']);
}
/// Get a specific conversation
Future<Map<String, dynamic>> getConversation(String conversationId) async {
final response = await _makeRequest('GET', 'conversations/$conversationId');
return Map<String, dynamic>.from(response);
}
/// Create a new conversation
Future<Map<String, dynamic>> createConversation(Map<String, dynamic> conversation) async {
final response = await _makeRequest('POST', 'conversations', data: {'conversation': conversation});
return Map<String, dynamic>.from(response);
}
/// Update an existing conversation
Future<Map<String, dynamic>> updateConversation(Map<String, dynamic> conversation) async {
final conversationId = conversation['id'];
final response = await _makeRequest('PUT', 'conversations/$conversationId', data: {'conversation': conversation});
return Map<String, dynamic>.from(response);
}
/// Delete a conversation
Future<bool> 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<Map<String, dynamic>> getSettings() async {
final response = await _makeRequest('GET', 'settings');
return Map<String, dynamic>.from(response['settings']);
}
/// Update settings for the authenticated user
Future<Map<String, dynamic>> updateSettings(Map<String, dynamic> settings) async {
final response = await _makeRequest('PUT', 'settings', data: {'settings': settings});
return Map<String, dynamic>.from(response);
}
}

View File

@ -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)