(Feat): Added a minimal pikanetwork client
This commit is contained in:
315
src/commands/developer/database.ts
Normal file
315
src/commands/developer/database.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* 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]}`;
|
||||
}
|
||||
Reference in New Issue
Block a user