316 lines
8.8 KiB
TypeScript
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]}`;
|
|
}
|