Files
EllyDiscordBot/src/commands/developer/database.ts

316 lines
8.8 KiB
TypeScript

/**
* Database Command
* Manage and inspect the database
*/
import {
SlashCommandBuilder,
EmbedBuilder,
type ChatInputCommandInteraction,
codeBlock,
} from 'discord.js';
import type { Command } from '../../types/index.ts';
import { PermissionLevel } from '../../types/index.ts';
import type { EllyClient } from '../../client/EllyClient.ts';
export const databaseCommand: Command = {
data: new SlashCommandBuilder()
.setName('database')
.setDescription('Database management commands')
.addSubcommand((sub) =>
sub
.setName('stats')
.setDescription('View database statistics')
)
.addSubcommand((sub) =>
sub
.setName('backup')
.setDescription('Create a database backup')
)
.addSubcommand((sub) =>
sub
.setName('query')
.setDescription('Execute a read-only SQL query')
.addStringOption((opt) =>
opt
.setName('sql')
.setDescription('The SQL query to execute')
.setRequired(true)
)
)
.addSubcommand((sub) =>
sub
.setName('tables')
.setDescription('List all database tables')
)
.addSubcommand((sub) =>
sub
.setName('vacuum')
.setDescription('Optimize the database')
),
permission: PermissionLevel.Developer,
cooldown: 5,
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const client = interaction.client as EllyClient;
const subcommand = interaction.options.getSubcommand();
// Check if SQLite database is available
if (!client.dbManager) {
await interaction.reply({
content: '❌ SQLite database is not initialized. Using legacy JSON database.',
ephemeral: true,
});
return;
}
switch (subcommand) {
case 'stats':
await handleStats(interaction, client);
break;
case 'backup':
await handleBackup(interaction, client);
break;
case 'query':
await handleQuery(interaction, client);
break;
case 'tables':
await handleTables(interaction, client);
break;
case 'vacuum':
await handleVacuum(interaction, client);
break;
}
},
};
async function handleStats(
interaction: ChatInputCommandInteraction,
client: EllyClient
): Promise<void> {
await interaction.deferReply({ ephemeral: true });
try {
const stats = await client.dbManager!.getStats();
const embed = new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle('📊 Database Statistics')
.addFields(
{ name: '📁 Path', value: `\`${stats.path}\``, inline: false },
{ name: '💾 Size', value: formatBytes(stats.size), inline: true },
{ name: '🔌 Connected', value: stats.connected ? '✅ Yes' : '❌ No', inline: true },
{ name: '📋 Tables', value: String(stats.tables.length), inline: true },
{ name: '📝 Table List', value: stats.tables.join(', ') || 'None' }
)
.setTimestamp();
await interaction.editReply({ embeds: [embed] });
} catch (error) {
await interaction.editReply({
content: `❌ Failed to get database stats: ${error}`,
});
}
}
async function handleBackup(
interaction: ChatInputCommandInteraction,
client: EllyClient
): Promise<void> {
await interaction.deferReply({ ephemeral: true });
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = `./data/backups/elly_${timestamp}.sqlite`;
// Ensure backup directory exists
await Deno.mkdir('./data/backups', { recursive: true });
await client.dbManager!.backup(backupPath);
const embed = new EmbedBuilder()
.setColor(0x57F287)
.setTitle('✅ Backup Created')
.addFields(
{ name: '📁 Backup Path', value: `\`${backupPath}\`` },
{ name: '⏰ Created At', value: new Date().toISOString() }
)
.setTimestamp();
await interaction.editReply({ embeds: [embed] });
} catch (error) {
await interaction.editReply({
content: `❌ Failed to create backup: ${error}`,
});
}
}
async function handleQuery(
interaction: ChatInputCommandInteraction,
client: EllyClient
): Promise<void> {
const sql = interaction.options.getString('sql', true);
// Security: Only allow SELECT queries
const normalizedSql = sql.trim().toLowerCase();
if (!normalizedSql.startsWith('select') && !normalizedSql.startsWith('pragma')) {
await interaction.reply({
content: '❌ Only SELECT and PRAGMA queries are allowed.',
ephemeral: true,
});
return;
}
// Block dangerous patterns
const dangerousPatterns = [
/drop\s+table/i,
/delete\s+from/i,
/truncate/i,
/insert\s+into/i,
/update\s+\w+\s+set/i,
/alter\s+table/i,
/create\s+table/i,
];
for (const pattern of dangerousPatterns) {
if (pattern.test(sql)) {
await interaction.reply({
content: '❌ Query contains forbidden patterns.',
ephemeral: true,
});
return;
}
}
await interaction.deferReply({ ephemeral: true });
try {
const startTime = performance.now();
const result = client.dbManager!.connection.query(sql);
const endTime = performance.now();
if (!result.success) {
await interaction.editReply({
content: `❌ Query failed: ${result.error?.message}`,
});
return;
}
const rows = result.data ?? [];
let output = JSON.stringify(rows, null, 2);
if (output.length > 1800) {
output = output.substring(0, 1800) + '\n... (truncated)';
}
const embed = new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle('📊 Query Result')
.addFields(
{ name: '📝 Query', value: codeBlock('sql', sql.substring(0, 500)) },
{ name: '📤 Result', value: codeBlock('json', output) },
{ name: '📊 Rows', value: String(rows.length), inline: true },
{ name: '⏱️ Time', value: `${(endTime - startTime).toFixed(2)}ms`, inline: true }
)
.setTimestamp();
await interaction.editReply({ embeds: [embed] });
} catch (error) {
await interaction.editReply({
content: `❌ Query error: ${error}`,
});
}
}
async function handleTables(
interaction: ChatInputCommandInteraction,
client: EllyClient
): Promise<void> {
await interaction.deferReply({ ephemeral: true });
try {
const result = client.dbManager!.connection.query<{ name: string; sql: string }>(
"SELECT name, sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
);
if (!result.success || !result.data) {
await interaction.editReply({
content: `❌ Failed to list tables: ${result.error?.message}`,
});
return;
}
const tables = result.data;
const tableInfo = tables.map((t) => {
// Get row count
const countResult = client.dbManager!.connection.queryOne<{ count: number }>(
`SELECT COUNT(*) as count FROM ${t.name}`
);
const count = countResult.success ? countResult.data?.count ?? 0 : 0;
return `**${t.name}** - ${count} rows`;
});
const embed = new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle('📋 Database Tables')
.setDescription(tableInfo.join('\n') || 'No tables found')
.addFields({
name: 'Total Tables',
value: String(tables.length),
inline: true,
})
.setTimestamp();
await interaction.editReply({ embeds: [embed] });
} catch (error) {
await interaction.editReply({
content: `❌ Failed to list tables: ${error}`,
});
}
}
async function handleVacuum(
interaction: ChatInputCommandInteraction,
client: EllyClient
): Promise<void> {
await interaction.deferReply({ ephemeral: true });
try {
const sizeBefore = await client.dbManager!.connection.getSize();
await client.dbManager!.vacuum();
const sizeAfter = await client.dbManager!.connection.getSize();
const saved = sizeBefore - sizeAfter;
const embed = new EmbedBuilder()
.setColor(0x57F287)
.setTitle('✅ Database Optimized')
.addFields(
{ name: 'Size Before', value: formatBytes(sizeBefore), inline: true },
{ name: 'Size After', value: formatBytes(sizeAfter), inline: true },
{ name: 'Space Saved', value: formatBytes(saved), inline: true }
)
.setTimestamp();
await interaction.editReply({ embeds: [embed] });
} catch (error) {
await interaction.editReply({
content: `❌ Failed to vacuum database: ${error}`,
});
}
}
function formatBytes(bytes: number): string {
const units = ['B', 'KB', 'MB', 'GB'];
let unitIndex = 0;
let size = bytes;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
}