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