feat: add count-up Stopwatch module with persistence in SQLite
All checks were successful
Auto Build and Push Docker Image / build (push) Successful in 7s
All checks were successful
Auto Build and Push Docker Image / build (push) Successful in 7s
This commit is contained in:
@@ -21,6 +21,7 @@ The bot uses a **Modular Command & Event Loading** pattern with **ESM (ECMAScrip
|
|||||||
12. **Role Selection System:** Self-service role assignment via select menus with support for exclusive categories and max-role limits.
|
12. **Role Selection System:** Self-service role assignment via select menus with support for exclusive categories and max-role limits.
|
||||||
13. **Cat & CatGIF System:** TheCatAPI integration for random cat images and GIFs (`CatManager`).
|
13. **Cat & CatGIF System:** TheCatAPI integration for random cat images and GIFs (`CatManager`).
|
||||||
14. **Centralized Event Logger:** Kapsel event logs into `EventLogger.sendLog()` to eliminate duplicated logging logic across event files.
|
14. **Centralized Event Logger:** Kapsel event logs into `EventLogger.sendLog()` to eliminate duplicated logging logic across event files.
|
||||||
|
15. **Stopwatch System:** Persistent count-up timer tracking stored in SQLite (`stopwatches` table) calculating elapsed time dynamically on query.
|
||||||
|
|
||||||
## 📁 Project Structure
|
## 📁 Project Structure
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,13 @@ npm run deploy
|
|||||||
- `/timer listall` – Alle Timer (Mod)
|
- `/timer listall` – Alle Timer (Mod)
|
||||||
- `/timer help` – Timer-Hilfe
|
- `/timer help` – Timer-Hilfe
|
||||||
|
|
||||||
|
### Stoppuhr (Hochzählender Timer)
|
||||||
|
- `/stopwatch start <name>` – Startet eine neue Stoppuhr mit Namen
|
||||||
|
- `/stopwatch status [name]` – Abgelaufene Zeit abfragen
|
||||||
|
- `/stopwatch stop <name>` – Stoppuhr beenden & Laufzeit ausgeben
|
||||||
|
- `/stopwatch list` – Aktive Stoppuhren auflisten
|
||||||
|
- `/stopwatch help` – Stoppuhr-Hilfe
|
||||||
|
|
||||||
### Trigger (Auto-Antworten)
|
### Trigger (Auto-Antworten)
|
||||||
- `/trigger add <wort> <antwort>` – Trigger hinzufügen (Mod)
|
- `/trigger add <wort> <antwort>` – Trigger hinzufügen (Mod)
|
||||||
- `/trigger remove <wort>` – Trigger entfernen (Mod)
|
- `/trigger remove <wort>` – Trigger entfernen (Mod)
|
||||||
|
|||||||
270
src/commands/utility/stopwatch.ts
Normal file
270
src/commands/utility/stopwatch.ts
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
import { SlashCommandBuilder, EmbedBuilder } from 'discord.js';
|
||||||
|
import { Command } from '../../structures/Command.js';
|
||||||
|
import { ExtendedClient } from '../../structures/ExtendedClient.js';
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const days = Math.floor(totalSeconds / 86400);
|
||||||
|
const hours = Math.floor((totalSeconds % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (days > 0) parts.push(`${days}d`);
|
||||||
|
if (hours > 0) parts.push(`${hours}h`);
|
||||||
|
if (minutes > 0) parts.push(`${minutes}m`);
|
||||||
|
parts.push(`${seconds}s`);
|
||||||
|
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
const command: Command = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('stopwatch')
|
||||||
|
.setDescription('Hochzählende Stoppuhren verwalten.')
|
||||||
|
.addSubcommand(subcommand =>
|
||||||
|
subcommand
|
||||||
|
.setName('start')
|
||||||
|
.setDescription('Startet eine neue Stoppuhr.')
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('name')
|
||||||
|
.setDescription('Der Name der Stoppuhr (z.B. "Gaming", "Lernen")')
|
||||||
|
.setRequired(true)))
|
||||||
|
.addSubcommand(subcommand =>
|
||||||
|
subcommand
|
||||||
|
.setName('status')
|
||||||
|
.setDescription('Zeigt den aktuellen Stand einer oder aller Stoppuhren an.')
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('name')
|
||||||
|
.setDescription('Der Name der Stoppuhr (optional)')
|
||||||
|
.setRequired(false)))
|
||||||
|
.addSubcommand(subcommand =>
|
||||||
|
subcommand
|
||||||
|
.setName('stop')
|
||||||
|
.setDescription('Stoppt eine laufende Stoppuhr.')
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('name')
|
||||||
|
.setDescription('Der Name der zu stoppenden Stoppuhr')
|
||||||
|
.setRequired(true)))
|
||||||
|
.addSubcommand(subcommand =>
|
||||||
|
subcommand
|
||||||
|
.setName('list')
|
||||||
|
.setDescription('Listet alle deine aktiven Stoppuhren auf.'))
|
||||||
|
.addSubcommand(subcommand =>
|
||||||
|
subcommand
|
||||||
|
.setName('help')
|
||||||
|
.setDescription('Zeigt Hilfe zu den Stoppuhr-Befehlen an.'))
|
||||||
|
.setDMPermission(true),
|
||||||
|
category: 'Public',
|
||||||
|
async execute(interaction: any) {
|
||||||
|
const client = interaction.client as ExtendedClient;
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
const guildId = interaction.guildId;
|
||||||
|
const userId = interaction.user.id;
|
||||||
|
const channelId = interaction.channelId;
|
||||||
|
const DB = client.DB;
|
||||||
|
|
||||||
|
if (subcommand === 'help') {
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle('⏱️ Stoppuhr Hilfe')
|
||||||
|
.setColor(0x3498db)
|
||||||
|
.setDescription('Verwalte hochzählende Timer (Stoppuhren).')
|
||||||
|
.addFields(
|
||||||
|
{ name: '`/stopwatch start <name>`', value: 'Startet eine neue Stoppuhr mit dem angegebenen Namen.', inline: false },
|
||||||
|
{ name: '`/stopwatch status [name]`', value: 'Zeigt die aktuelle Laufzeit einer oder aller Stoppuhren an.', inline: false },
|
||||||
|
{ name: '`/stopwatch stop <name>`', value: 'Stoppt eine Stoppuhr und gibt die finale Laufzeit aus.', inline: false },
|
||||||
|
{ name: '`/stopwatch list`', value: 'Listet alle deine aktiven Stoppuhren auf diesem Server auf.', inline: false },
|
||||||
|
{ name: 'Beispiele', value: '`/stopwatch start name: Zocksession`\n`/stopwatch status name: Zocksession`\n`/stopwatch stop name: Zocksession`', inline: false }
|
||||||
|
)
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'start') {
|
||||||
|
const name = interaction.options.getString('name')!.trim();
|
||||||
|
|
||||||
|
const existing: any = DB.get(
|
||||||
|
'SELECT * FROM stopwatches WHERE guild_id = ? AND user_id = ? AND name = ?',
|
||||||
|
guildId,
|
||||||
|
userId,
|
||||||
|
name
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const startTime = new Date(existing.start_time);
|
||||||
|
const unixTs = Math.floor(startTime.getTime() / 1000);
|
||||||
|
await interaction.reply({
|
||||||
|
content: `⚠️ Du hast bereits eine Stoppuhr namens **"${name}"** laufen (gestartet <t:${unixTs}:R>).\nNutze \`/stopwatch status name:${name}\` oder \`/stopwatch stop name:${name}\`.`,
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB.run(
|
||||||
|
'INSERT INTO stopwatches (guild_id, channel_id, user_id, name, start_time) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)',
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
userId,
|
||||||
|
name
|
||||||
|
);
|
||||||
|
|
||||||
|
const nowUnix = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(`⏱️ Stoppuhr "${name}" gestartet`)
|
||||||
|
.setColor(0x2ecc71)
|
||||||
|
.addFields(
|
||||||
|
{ name: 'Name', value: name, inline: true },
|
||||||
|
{ name: 'Gestartet um', value: `<t:${nowUnix}:F> (<t:${nowUnix}:R>)`, inline: true }
|
||||||
|
)
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'status') {
|
||||||
|
const name = interaction.options.getString('name')?.trim();
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
const sw: any = DB.get(
|
||||||
|
'SELECT * FROM stopwatches WHERE guild_id = ? AND user_id = ? AND name = ?',
|
||||||
|
guildId,
|
||||||
|
userId,
|
||||||
|
name
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sw) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: `❌ Keine aktive Stoppuhr namens **"${name}"** gefunden.`,
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = new Date(sw.start_time);
|
||||||
|
const elapsedMs = Date.now() - startTime.getTime();
|
||||||
|
const unixTs = Math.floor(startTime.getTime() / 1000);
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(`⏱️ Stoppuhr Status: "${sw.name}"`)
|
||||||
|
.setColor(0x3498db)
|
||||||
|
.addFields(
|
||||||
|
{ name: 'Aktuelle Laufzeit', value: `**${formatDuration(elapsedMs)}**`, inline: true },
|
||||||
|
{ name: 'Gestartet', value: `<t:${unixTs}:R>`, inline: true },
|
||||||
|
{ name: 'Startzeitpunkt', value: `<t:${unixTs}:F>`, inline: false }
|
||||||
|
)
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
const stopwatches: any[] = DB.all(
|
||||||
|
'SELECT * FROM stopwatches WHERE guild_id = ? AND user_id = ? ORDER BY start_time ASC',
|
||||||
|
guildId,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (stopwatches.length === 0) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'ℹ️ Du hast aktuell keine aktiven Stoppuhren.',
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const listStr = stopwatches.map((sw: any) => {
|
||||||
|
const startTime = new Date(sw.start_time);
|
||||||
|
const elapsedMs = now - startTime.getTime();
|
||||||
|
const unixTs = Math.floor(startTime.getTime() / 1000);
|
||||||
|
return `• **${sw.name}**: **${formatDuration(elapsedMs)}** (seit <t:${unixTs}:R>)`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(`⏱️ Deine aktiven Stoppuhren (${stopwatches.length})`)
|
||||||
|
.setColor(0x3498db)
|
||||||
|
.setDescription(listStr)
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'stop') {
|
||||||
|
const name = interaction.options.getString('name')!.trim();
|
||||||
|
|
||||||
|
const sw: any = DB.get(
|
||||||
|
'SELECT * FROM stopwatches WHERE guild_id = ? AND user_id = ? AND name = ?',
|
||||||
|
guildId,
|
||||||
|
userId,
|
||||||
|
name
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sw) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: `❌ Keine aktive Stoppuhr namens **"${name}"** gefunden.`,
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = new Date(sw.start_time);
|
||||||
|
const elapsedMs = Date.now() - startTime.getTime();
|
||||||
|
const startUnix = Math.floor(startTime.getTime() / 1000);
|
||||||
|
const endUnix = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
DB.run('DELETE FROM stopwatches WHERE id = ?', sw.id);
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(`⏹️ Stoppuhr "${name}" beendet`)
|
||||||
|
.setColor(0xe74c3c)
|
||||||
|
.addFields(
|
||||||
|
{ name: 'Gesamtlaufzeit', value: `**${formatDuration(elapsedMs)}**`, inline: true },
|
||||||
|
{ name: 'Gestartet', value: `<t:${startUnix}:F>`, inline: false },
|
||||||
|
{ name: 'Beendet um', value: `<t:${endUnix}:F>`, inline: false }
|
||||||
|
)
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'list') {
|
||||||
|
const stopwatches: any[] = DB.all(
|
||||||
|
'SELECT * FROM stopwatches WHERE guild_id = ? AND user_id = ? ORDER BY start_time ASC',
|
||||||
|
guildId,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (stopwatches.length === 0) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'ℹ️ Du hast aktuell keine aktiven Stoppuhren.',
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const listStr = stopwatches.map((sw: any) => {
|
||||||
|
const startTime = new Date(sw.start_time);
|
||||||
|
const elapsedMs = now - startTime.getTime();
|
||||||
|
const unixTs = Math.floor(startTime.getTime() / 1000);
|
||||||
|
return `• **${sw.name}**: **${formatDuration(elapsedMs)}** (gestartet <t:${unixTs}:R>)`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(`⏱️ Aktive Stoppuhren (${stopwatches.length})`)
|
||||||
|
.setColor(0x3498db)
|
||||||
|
.setDescription(listStr)
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default command;
|
||||||
@@ -151,10 +151,24 @@ export class DB {
|
|||||||
)
|
)
|
||||||
`).run();
|
`).run();
|
||||||
|
|
||||||
|
// Stopwatches (Count-up Timers)
|
||||||
|
db.prepare(`
|
||||||
|
CREATE TABLE IF NOT EXISTS stopwatches (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
guild_id TEXT NOT NULL,
|
||||||
|
channel_id TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
start_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(guild_id, user_id, name)
|
||||||
|
)
|
||||||
|
`).run();
|
||||||
|
|
||||||
// Performance Indexes
|
// Performance Indexes
|
||||||
db.prepare('CREATE INDEX IF NOT EXISTS idx_mod_logs_guild_user ON mod_logs(guild_id, user_id, action)').run();
|
db.prepare('CREATE INDEX IF NOT EXISTS idx_mod_logs_guild_user ON mod_logs(guild_id, user_id, action)').run();
|
||||||
db.prepare('CREATE INDEX IF NOT EXISTS idx_reminders_target ON reminders(target_time)').run();
|
db.prepare('CREATE INDEX IF NOT EXISTS idx_reminders_target ON reminders(target_time)').run();
|
||||||
db.prepare('CREATE INDEX IF NOT EXISTS idx_twitch_monitors_guild ON twitch_monitors(guild_id)').run();
|
db.prepare('CREATE INDEX IF NOT EXISTS idx_twitch_monitors_guild ON twitch_monitors(guild_id)').run();
|
||||||
|
db.prepare('CREATE INDEX IF NOT EXISTS idx_stopwatches_guild_user ON stopwatches(guild_id, user_id)').run();
|
||||||
|
|
||||||
// Migration logic
|
// Migration logic
|
||||||
const columns = db.prepare("PRAGMA table_info(guild_settings)").all() as any[];
|
const columns = db.prepare("PRAGMA table_info(guild_settings)").all() as any[];
|
||||||
|
|||||||
Reference in New Issue
Block a user