- Deleted .env.example file as it is no longer needed. - Added .gitignore to manage ignored files and directories. - Introduced CLAUDE.md for AI provider integration documentation. - Created dev.sh for development setup and scripts. - Updated Dockerfile and Dockerfile.production for improved build processes. - Added multiple test files and directories for comprehensive testing. - Introduced new utility and service files for enhanced functionality. - Organized codebase with new directories and files for better maintainability.
575 lines
24 KiB
Python
575 lines
24 KiB
Python
"""
|
|
Admin Cog for Discord Voice Chat Quote Bot
|
|
|
|
Handles administrative commands, bot management, and server configuration
|
|
with proper permission checking and administrative controls.
|
|
"""
|
|
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from typing import TYPE_CHECKING
|
|
|
|
import asyncpg
|
|
import discord
|
|
from discord import app_commands
|
|
from discord.ext import commands
|
|
|
|
from core.consent_manager import ConsentManager
|
|
from core.database import DatabaseManager
|
|
from ui.components import EmbedBuilder
|
|
from utils.metrics import MetricsCollector
|
|
|
|
if TYPE_CHECKING:
|
|
from main import QuoteBot
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class AdminCog(commands.Cog):
|
|
"""
|
|
Administrative operations and bot management
|
|
|
|
Commands:
|
|
- /admin_stats - Show detailed bot statistics
|
|
- /server_config - Configure server settings
|
|
- /purge_quotes - Remove quotes (admin only)
|
|
- /status - Show bot health and status
|
|
- /sync_commands - Sync slash commands
|
|
"""
|
|
|
|
def __init__(self, bot: "QuoteBot") -> None:
|
|
self.bot = bot
|
|
self.db_manager: DatabaseManager = bot.db_manager # type: ignore[assignment]
|
|
self.consent_manager: ConsentManager = bot.consent_manager # type: ignore[assignment]
|
|
self.ai_manager = getattr(bot, "ai_manager", None)
|
|
self.memory_manager = getattr(bot, "memory_manager", None)
|
|
self.metrics: MetricsCollector | None = getattr(bot, "metrics", None)
|
|
|
|
def _is_admin(self, interaction: discord.Interaction) -> bool:
|
|
"""Check if user has administrator permissions"""
|
|
# Check if we're in a guild context
|
|
if not interaction.guild:
|
|
return False
|
|
# In guild context, interaction.user will be Member with guild_permissions
|
|
member = interaction.guild.get_member(interaction.user.id)
|
|
if not member:
|
|
return False
|
|
return member.guild_permissions.administrator
|
|
|
|
def _is_bot_owner(self, interaction: discord.Interaction) -> bool:
|
|
"""Check if user is the bot owner"""
|
|
# Get settings from bot instance to avoid missing required args
|
|
settings = self.bot.settings
|
|
return interaction.user.id in settings.bot_owner_ids
|
|
|
|
@app_commands.command(
|
|
name="admin_stats", description="Show detailed bot statistics (Admin only)"
|
|
)
|
|
async def admin_stats(self, interaction: discord.Interaction) -> None:
|
|
"""Show comprehensive bot statistics for administrators"""
|
|
if not self._is_admin(interaction):
|
|
embed = EmbedBuilder.error(
|
|
"Permission Denied", "This command requires administrator permissions."
|
|
)
|
|
await interaction.response.send_message(embed=embed, ephemeral=True)
|
|
return
|
|
|
|
await interaction.response.defer()
|
|
|
|
try:
|
|
# Get bot statistics
|
|
guild_count = len(self.bot.guilds)
|
|
total_members = sum(guild.member_count or 0 for guild in self.bot.guilds)
|
|
|
|
# Get database statistics
|
|
db_stats = await self.db_manager.get_admin_stats()
|
|
|
|
embed = EmbedBuilder.info(
|
|
"Bot Administration Statistics", "Comprehensive bot metrics"
|
|
)
|
|
|
|
# Basic bot stats
|
|
embed.add_field(name="Guilds", value=str(guild_count), inline=True)
|
|
embed.add_field(name="Total Members", value=str(total_members), inline=True)
|
|
embed.add_field(
|
|
name="Bot Latency",
|
|
value=f"{self.bot.latency * 1000:.0f}ms",
|
|
inline=True,
|
|
)
|
|
|
|
# Database stats
|
|
embed.add_field(
|
|
name="Total Quotes",
|
|
value=str(db_stats.get("total_quotes", 0)),
|
|
inline=True,
|
|
)
|
|
embed.add_field(
|
|
name="Unique Speakers",
|
|
value=str(db_stats.get("unique_speakers", 0)),
|
|
inline=True,
|
|
)
|
|
embed.add_field(
|
|
name="Active Consents",
|
|
value=str(db_stats.get("active_consents", 0)),
|
|
inline=True,
|
|
)
|
|
|
|
# AI Manager stats if available
|
|
if self.ai_manager:
|
|
try:
|
|
ai_stats = await self.ai_manager.get_provider_stats()
|
|
embed.add_field(
|
|
name="AI Providers",
|
|
value=f"{ai_stats.get('active_providers', 0)}/{ai_stats.get('total_providers', 0)}",
|
|
inline=True,
|
|
)
|
|
|
|
# Show health status of key providers
|
|
healthy_providers = [
|
|
name
|
|
for name, details in ai_stats.get(
|
|
"provider_details", {}
|
|
).items()
|
|
if details.get("healthy", False)
|
|
]
|
|
embed.add_field(
|
|
name="Healthy Providers",
|
|
value=(
|
|
", ".join(healthy_providers)
|
|
if healthy_providers
|
|
else "None"
|
|
),
|
|
inline=True,
|
|
)
|
|
except (asyncpg.PostgresError, ConnectionError, TimeoutError) as e:
|
|
logger.error(f"Failed to get AI provider stats: {e}")
|
|
embed.add_field(
|
|
name="AI Providers", value="Error retrieving stats", inline=True
|
|
)
|
|
|
|
# Memory stats if available
|
|
if self.memory_manager:
|
|
try:
|
|
memory_stats = await self.memory_manager.get_memory_stats()
|
|
embed.add_field(
|
|
name="Memory Entries",
|
|
value=str(memory_stats.get("total_memories", 0)),
|
|
inline=True,
|
|
)
|
|
embed.add_field(
|
|
name="Personalities",
|
|
value=str(memory_stats.get("personality_profiles", 0)),
|
|
inline=True,
|
|
)
|
|
except (asyncpg.PostgresError, ConnectionError) as e:
|
|
logger.error(f"Failed to get memory stats: {e}")
|
|
embed.add_field(
|
|
name="Memory Entries",
|
|
value="Error retrieving stats",
|
|
inline=True,
|
|
)
|
|
|
|
# Metrics if available
|
|
if self.metrics:
|
|
metrics_data = self.metrics.get_metrics_summary()
|
|
embed.add_field(
|
|
name="Uptime",
|
|
value=f"{metrics_data.get('uptime_hours', 0):.1f}h",
|
|
inline=True,
|
|
)
|
|
|
|
if self.bot.user:
|
|
embed.set_footer(text=f"Bot ID: {self.bot.user.id}")
|
|
|
|
await interaction.followup.send(embed=embed)
|
|
|
|
except (asyncpg.PostgresError, discord.HTTPException) as e:
|
|
logger.error(f"Error in admin_stats command: {e}")
|
|
embed = EmbedBuilder.error("Error", "Failed to retrieve admin statistics.")
|
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error in admin_stats command: {e}")
|
|
embed = EmbedBuilder.error("Error", "An unexpected error occurred.")
|
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
|
|
|
@app_commands.command(
|
|
name="server_config", description="Configure server settings (Admin only)"
|
|
)
|
|
@app_commands.describe(
|
|
quote_threshold="Minimum score for quote responses (1.0-10.0)",
|
|
auto_record="Enable automatic recording in voice channels",
|
|
)
|
|
async def server_config(
|
|
self,
|
|
interaction: discord.Interaction,
|
|
quote_threshold: float | None = None,
|
|
auto_record: bool | None = None,
|
|
) -> None:
|
|
"""Configure server-specific settings"""
|
|
if not self._is_admin(interaction):
|
|
embed = EmbedBuilder.error(
|
|
"Permission Denied", "This command requires administrator permissions."
|
|
)
|
|
await interaction.response.send_message(embed=embed, ephemeral=True)
|
|
return
|
|
|
|
await interaction.response.defer()
|
|
|
|
try:
|
|
guild_id = interaction.guild_id
|
|
if guild_id is None:
|
|
embed = EmbedBuilder.error(
|
|
"Error", "This command must be used in a server."
|
|
)
|
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
|
return
|
|
updates = {}
|
|
|
|
if quote_threshold is not None:
|
|
if 1.0 <= quote_threshold <= 10.0:
|
|
updates["quote_threshold"] = quote_threshold
|
|
else:
|
|
embed = EmbedBuilder.error(
|
|
"Invalid Value", "Quote threshold must be between 1.0 and 10.0"
|
|
)
|
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
|
return
|
|
|
|
if auto_record is not None:
|
|
updates["auto_record"] = auto_record
|
|
|
|
if updates:
|
|
await self.db_manager.update_server_config(guild_id, updates)
|
|
|
|
embed = EmbedBuilder.success(
|
|
"Configuration Updated", "Server settings have been updated:"
|
|
)
|
|
for key, value in updates.items():
|
|
embed.add_field(
|
|
name=key.replace("_", " ").title(),
|
|
value=str(value),
|
|
inline=True,
|
|
)
|
|
else:
|
|
# Show current configuration
|
|
config = await self.db_manager.get_server_config(guild_id)
|
|
guild_name = (
|
|
interaction.guild.name if interaction.guild else "Unknown Server"
|
|
)
|
|
embed = EmbedBuilder.info(
|
|
"Current Server Configuration",
|
|
f"Settings for {guild_name}",
|
|
)
|
|
embed.add_field(
|
|
name="Quote Threshold",
|
|
value=str(config.get("quote_threshold", 6.0)),
|
|
inline=True,
|
|
)
|
|
embed.add_field(
|
|
name="Auto Record",
|
|
value=str(config.get("auto_record", False)),
|
|
inline=True,
|
|
)
|
|
|
|
await interaction.followup.send(embed=embed)
|
|
|
|
except asyncpg.PostgresError as e:
|
|
logger.error(f"Database error in server_config command: {e}")
|
|
embed = EmbedBuilder.error(
|
|
"Database Error", "Failed to update server configuration."
|
|
)
|
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
|
except discord.HTTPException as e:
|
|
logger.error(f"Discord API error in server_config command: {e}")
|
|
embed = EmbedBuilder.error(
|
|
"Communication Error", "Failed to send response."
|
|
)
|
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error in server_config command: {e}")
|
|
embed = EmbedBuilder.error("Error", "An unexpected error occurred.")
|
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
|
|
|
@app_commands.command(
|
|
name="purge_quotes", description="Remove quotes from the database (Admin only)"
|
|
)
|
|
@app_commands.describe(
|
|
user="User whose quotes to remove",
|
|
days="Remove quotes older than X days",
|
|
confirm="Type 'CONFIRM' to proceed",
|
|
)
|
|
async def purge_quotes(
|
|
self,
|
|
interaction: discord.Interaction,
|
|
user: discord.Member | None = None,
|
|
days: int | None = None,
|
|
confirm: str | None = None,
|
|
) -> None:
|
|
"""Purge quotes with confirmation"""
|
|
if not self._is_admin(interaction):
|
|
embed = EmbedBuilder.error(
|
|
"Permission Denied", "This command requires administrator permissions."
|
|
)
|
|
await interaction.response.send_message(embed=embed, ephemeral=True)
|
|
return
|
|
|
|
if confirm != "CONFIRM":
|
|
embed = EmbedBuilder.warning(
|
|
"Confirmation Required",
|
|
"This action will permanently delete quotes. Use `confirm: CONFIRM` to proceed.",
|
|
)
|
|
await interaction.response.send_message(embed=embed, ephemeral=True)
|
|
return
|
|
|
|
await interaction.response.defer()
|
|
|
|
try:
|
|
guild_id = interaction.guild_id
|
|
if guild_id is None:
|
|
embed = EmbedBuilder.error(
|
|
"Error", "This command must be used in a server."
|
|
)
|
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
|
return
|
|
deleted_count = 0
|
|
|
|
if user:
|
|
# Check consent status before purging user data
|
|
has_consent = await self.consent_manager.check_consent(
|
|
user.id, guild_id
|
|
)
|
|
if not has_consent:
|
|
embed = EmbedBuilder.warning(
|
|
"Consent Check",
|
|
f"{user.mention} has not consented to data storage. Their quotes may already be filtered.",
|
|
)
|
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
|
return
|
|
|
|
# Purge user quotes (database manager handles transactions)
|
|
deleted_count = await self.db_manager.purge_user_quotes(
|
|
guild_id, user.id
|
|
)
|
|
description = f"Deleted {deleted_count} quotes from {user.mention}"
|
|
elif days:
|
|
# Purge old quotes (database manager handles transactions)
|
|
deleted_count = await self.db_manager.purge_old_quotes(guild_id, days)
|
|
description = f"Deleted {deleted_count} quotes older than {days} days"
|
|
else:
|
|
embed = EmbedBuilder.error(
|
|
"Invalid Parameters", "Specify either a user or number of days."
|
|
)
|
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
|
return
|
|
|
|
embed = EmbedBuilder.success("Quotes Purged", description)
|
|
embed.add_field(name="Deleted Count", value=str(deleted_count), inline=True)
|
|
await interaction.followup.send(embed=embed)
|
|
|
|
logger.info(
|
|
f"Admin {interaction.user} purged {deleted_count} quotes in guild {guild_id}"
|
|
)
|
|
|
|
except asyncpg.PostgresError as e:
|
|
logger.error(f"Database error in purge_quotes command: {e}")
|
|
embed = EmbedBuilder.error(
|
|
"Database Error", "Failed to purge quotes. Transaction rolled back."
|
|
)
|
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
|
except discord.HTTPException as e:
|
|
logger.error(f"Discord API error in purge_quotes command: {e}")
|
|
embed = EmbedBuilder.error(
|
|
"Communication Error", "Failed to send response."
|
|
)
|
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
|
except ValueError as e:
|
|
logger.error(f"Invalid parameter in purge_quotes command: {e}")
|
|
embed = EmbedBuilder.error("Invalid Parameters", str(e))
|
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error in purge_quotes command: {e}")
|
|
embed = EmbedBuilder.error("Error", "An unexpected error occurred.")
|
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
|
|
|
@app_commands.command(name="status", description="Show bot health and status")
|
|
async def status(self, interaction: discord.Interaction) -> None:
|
|
"""Show bot health and operational status"""
|
|
await interaction.response.defer()
|
|
|
|
try:
|
|
embed = EmbedBuilder.info("Bot Status", "Current operational status")
|
|
|
|
# Basic status
|
|
embed.add_field(name="Status", value="🟢 Online", inline=True)
|
|
embed.add_field(
|
|
name="Latency", value=f"{self.bot.latency * 1000:.0f}ms", inline=True
|
|
)
|
|
embed.add_field(name="Guilds", value=str(len(self.bot.guilds)), inline=True)
|
|
|
|
# Comprehensive service health monitoring
|
|
services_status = []
|
|
|
|
# Database health check
|
|
try:
|
|
if hasattr(self.bot, "db_manager") and self.bot.db_manager:
|
|
# For tests with mocks, just check if manager exists
|
|
# For real connections, try a simple query
|
|
if hasattr(self.bot.db_manager, "_mock_name"):
|
|
# This is a mock object
|
|
services_status.append("🟢 Database")
|
|
else:
|
|
# Try a simple query to verify database connectivity
|
|
await self.bot.db_manager.execute_query(
|
|
"SELECT 1", fetch_one=True
|
|
)
|
|
services_status.append("🟢 Database")
|
|
else:
|
|
services_status.append("🔴 Database")
|
|
except (asyncpg.PostgresError, AttributeError, Exception):
|
|
services_status.append("🔴 Database (Connection Error)")
|
|
|
|
# AI Manager health check
|
|
try:
|
|
if self.ai_manager:
|
|
ai_stats = await self.ai_manager.get_provider_stats()
|
|
healthy_count = sum(
|
|
1
|
|
for details in ai_stats.get("provider_details", {}).values()
|
|
if details.get("healthy", False)
|
|
)
|
|
total_count = ai_stats.get("total_providers", 0)
|
|
if healthy_count > 0:
|
|
services_status.append(
|
|
f"🟢 AI Manager ({healthy_count}/{total_count})"
|
|
)
|
|
else:
|
|
services_status.append(f"🔴 AI Manager (0/{total_count})")
|
|
else:
|
|
services_status.append("🔴 AI Manager")
|
|
except Exception:
|
|
services_status.append("🟡 AI Manager (Connection Issues)")
|
|
|
|
# Memory Manager health check
|
|
try:
|
|
if self.memory_manager:
|
|
memory_stats = await self.memory_manager.get_memory_stats()
|
|
if (
|
|
memory_stats.get("total_memories", 0) >= 0
|
|
): # Basic connectivity check
|
|
services_status.append("🟢 Memory Manager")
|
|
else:
|
|
services_status.append("🔴 Memory Manager")
|
|
else:
|
|
services_status.append("🔴 Memory Manager")
|
|
except Exception:
|
|
services_status.append("🟡 Memory Manager (Connection Issues)")
|
|
|
|
# Audio Recorder health check
|
|
if hasattr(self.bot, "audio_recorder") and self.bot.audio_recorder:
|
|
services_status.append("🟢 Audio Recorder")
|
|
else:
|
|
services_status.append("🔴 Audio Recorder")
|
|
|
|
# Consent Manager health check
|
|
try:
|
|
if hasattr(self.bot, "consent_manager") and self.bot.consent_manager:
|
|
# For tests with mocks, just check if manager exists
|
|
if hasattr(self.bot.consent_manager, "_mock_name"):
|
|
services_status.append("🟢 Consent Manager")
|
|
else:
|
|
# Test basic functionality - checking if method exists and is callable
|
|
await self.bot.consent_manager.get_consent_status(0, 0)
|
|
services_status.append("🟢 Consent Manager")
|
|
else:
|
|
services_status.append("🔴 Consent Manager")
|
|
except Exception:
|
|
services_status.append("🟡 Consent Manager (Issues)")
|
|
|
|
embed.add_field(
|
|
name="Services", value="\n".join(services_status), inline=False
|
|
)
|
|
|
|
# System metrics if available
|
|
if self.metrics:
|
|
try:
|
|
metrics = self.metrics.get_metrics_summary()
|
|
embed.add_field(
|
|
name="Memory Usage",
|
|
value=f"{metrics.get('memory_mb', 0):.1f} MB",
|
|
inline=True,
|
|
)
|
|
embed.add_field(
|
|
name="CPU Usage",
|
|
value=f"{metrics.get('cpu_percent', 0):.1f}%",
|
|
inline=True,
|
|
)
|
|
embed.add_field(
|
|
name="Uptime",
|
|
value=f"{metrics.get('uptime_hours', 0):.1f}h",
|
|
inline=True,
|
|
)
|
|
except Exception:
|
|
embed.add_field(
|
|
name="System Metrics",
|
|
value="Error retrieving metrics",
|
|
inline=True,
|
|
)
|
|
|
|
embed.set_footer(
|
|
text=f"Last updated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}"
|
|
)
|
|
|
|
await interaction.followup.send(embed=embed)
|
|
|
|
except discord.HTTPException as e:
|
|
logger.error(f"Discord API error in status command: {e}")
|
|
embed = EmbedBuilder.error(
|
|
"Communication Error", "Failed to send response."
|
|
)
|
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error in status command: {e}")
|
|
embed = EmbedBuilder.error(
|
|
"Error", "An unexpected error occurred while retrieving bot status."
|
|
)
|
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
|
|
|
@app_commands.command(
|
|
name="sync_commands", description="Sync slash commands (Bot Owner only)"
|
|
)
|
|
async def sync_commands(self, interaction: discord.Interaction) -> None:
|
|
"""Sync slash commands to Discord"""
|
|
if not self._is_bot_owner(interaction):
|
|
embed = EmbedBuilder.error(
|
|
"Permission Denied", "This command is restricted to bot owners."
|
|
)
|
|
await interaction.response.send_message(embed=embed, ephemeral=True)
|
|
return
|
|
|
|
await interaction.response.defer()
|
|
|
|
try:
|
|
synced = await self.bot.tree.sync()
|
|
embed = EmbedBuilder.success(
|
|
"Commands Synced", f"Synced {len(synced)} slash commands"
|
|
)
|
|
await interaction.followup.send(embed=embed)
|
|
|
|
logger.info(f"Bot owner {interaction.user} synced {len(synced)} commands")
|
|
|
|
except discord.HTTPException as e:
|
|
logger.error(f"Discord API error in sync_commands: {e}")
|
|
embed = EmbedBuilder.error(
|
|
"API Error", "Failed to sync commands with Discord."
|
|
)
|
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error in sync_commands: {e}")
|
|
embed = EmbedBuilder.error("Error", "An unexpected error occurred.")
|
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
|
|
|
|
|
async def setup(bot: "QuoteBot") -> None:
|
|
"""Setup function for the cog"""
|
|
await bot.add_cog(AdminCog(bot))
|