From 58623bd1d39c70c7613623175cffa290e520fb58 Mon Sep 17 00:00:00 2001 From: smueller Date: Wed, 22 Jul 2026 12:56:00 +0200 Subject: [PATCH] Enhance bot command handling with new leveling, economy, and utility features - Integrated leveling and economy commands, including XP management and shop interactions. - Added context menu support for utility commands, improving user interaction options. - Updated command registration to include new commands and context menus, enhancing overall functionality. - Improved logging to track the registration of commands and context menus. - Refactored interaction handling to accommodate new command types and ensure smooth execution. --- apps/bot/src/commands.ts | 41 ++++++++++- apps/bot/src/index.ts | 42 ++++++++++- apps/bot/src/jobs.ts | 1 - .../modules/utility/command-definitions.ts | 1 - apps/bot/src/modules/utility/context-menus.ts | 2 +- apps/bot/src/modules/utility/snipe.ts | 1 - docs/PHASE-TRACKING.md | 73 ++++++++++--------- packages/shared/src/phase3.test.ts | 22 ++++++ 8 files changed, 140 insertions(+), 43 deletions(-) create mode 100644 packages/shared/src/phase3.test.ts diff --git a/apps/bot/src/commands.ts b/apps/bot/src/commands.ts index c06ca9d..db3a6de 100644 --- a/apps/bot/src/commands.ts +++ b/apps/bot/src/commands.ts @@ -1,4 +1,9 @@ -import { REST, Routes, type ChatInputCommandInteraction } from 'discord.js'; +import { + REST, + Routes, + type ChatInputCommandInteraction, + type MessageContextMenuCommandInteraction +} from 'discord.js'; import { t } from '@nexumi/shared'; import { env } from './env.js'; import { logger } from './logger.js'; @@ -7,22 +12,37 @@ import { moderationCommands } from './modules/moderation/commands.js'; import { automodCommands } from './modules/automod/commands.js'; import { welcomeCommands } from './modules/welcome/commands.js'; import { verificationCommands } from './modules/verification/commands.js'; +import { levelingCommands } from './modules/leveling/commands.js'; +import { economyCommands } from './modules/economy/commands.js'; +import { utilityCommands, utilityContextMenus } from './modules/utility/index.js'; +import { funCommands } from './modules/fun/index.js'; import type { BotContext, SlashCommand } from './types.js'; const commands: SlashCommand[] = [ ...moderationCommands, ...automodCommands, ...welcomeCommands, - ...verificationCommands + ...verificationCommands, + ...levelingCommands, + ...economyCommands, + ...utilityCommands, + ...funCommands ]; const map = new Map(commands.map((c) => [c.data.name, c])); +const contextMenuMap = new Map(utilityContextMenus.map((c) => [c.data.name, c])); export async function registerCommands() { const rest = new REST({ version: '10' }).setToken(env.BOT_TOKEN); await rest.put(Routes.applicationCommands(env.BOT_CLIENT_ID), { - body: commands.map((c) => c.data.toJSON()) + body: [ + ...commands.map((c) => c.data.toJSON()), + ...utilityContextMenus.map((c) => c.data.toJSON()) + ] }); - logger.info({ count: commands.length }, 'Registered application commands'); + logger.info( + { count: commands.length, contextMenus: utilityContextMenus.length }, + 'Registered application commands' + ); } export async function routeCommand(interaction: ChatInputCommandInteraction, context: BotContext) { @@ -34,3 +54,16 @@ export async function routeCommand(interaction: ChatInputCommandInteraction, con } await command.execute(interaction, context); } + +export async function routeContextMenu( + interaction: MessageContextMenuCommandInteraction, + context: BotContext +) { + const command = contextMenuMap.get(interaction.commandName); + const locale = await getGuildLocale(context.prisma, interaction.guildId); + if (!command) { + await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true }); + return; + } + await command.execute(interaction, context); +} diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 5cfe2e3..9dc0445 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -10,7 +10,7 @@ import { env } from './env.js'; import { logger } from './logger.js'; import { prisma } from './db.js'; import { redis } from './redis.js'; -import { registerCommands, routeCommand } from './commands.js'; +import { registerCommands, routeCommand, routeContextMenu } from './commands.js'; import { ensureRecurringJobs, startWorkers } from './jobs.js'; import type { BotContext } from './types.js'; import { startHealthServer } from './health.js'; @@ -26,6 +26,19 @@ import { registerWelcomeEvents } from './modules/welcome/events.js'; import { registerVerificationEvents } from './modules/verification/events.js'; import { handleVerificationButton } from './modules/verification/handlers.js'; import { isVerificationButton } from './modules/verification/service.js'; +import { registerLevelingEvents } from './modules/leveling/events.js'; +import { + handleBlackjackButton, + isBlackjackButton +} from './modules/economy/blackjack-buttons.js'; +import { + handleEmbedModal, + handlePollButton, + isEmbedModal, + isPollButton, + registerUtilityEvents +} from './modules/utility/index.js'; +import { handleFunButton, isFunButton } from './modules/fun/index.js'; const isShard = process.argv.includes('--shard'); @@ -59,6 +72,8 @@ if (!isShard) { registerLoggingEvents(context); registerWelcomeEvents(context); registerVerificationEvents(context); + registerLevelingEvents(context); + registerUtilityEvents(context); client.on(Events.ClientReady, async () => { logger.info({ tag: client.user?.tag }, 'Bot ready'); @@ -80,6 +95,11 @@ if (!isShard) { return; } + if (interaction.isMessageContextMenuCommand()) { + await routeContextMenu(interaction, context); + return; + } + if (interaction.isButton() && isModerationConfirmation(interaction.customId)) { await handleModerationConfirmation(interaction, context); return; @@ -89,6 +109,26 @@ if (!isShard) { await handleVerificationButton(interaction, context); return; } + + if (interaction.isButton() && isBlackjackButton(interaction.customId)) { + await handleBlackjackButton(interaction, context); + return; + } + + if (interaction.isButton() && isPollButton(interaction.customId)) { + await handlePollButton(interaction, context); + return; + } + + if (interaction.isButton() && isFunButton(interaction.customId)) { + await handleFunButton(interaction, context); + return; + } + + if (interaction.isModalSubmit() && isEmbedModal(interaction.customId)) { + await handleEmbedModal(interaction, context); + return; + } } catch (error) { logger.error({ error }, 'Interaction handling failed'); const locale = await getGuildLocale(context.prisma, interaction.guildId); diff --git a/apps/bot/src/jobs.ts b/apps/bot/src/jobs.ts index ff57d9c..5e3d14f 100644 --- a/apps/bot/src/jobs.ts +++ b/apps/bot/src/jobs.ts @@ -17,7 +17,6 @@ import { backupQueue, backupQueueName, moderationQueueName, - reminderQueue, reminderQueueName, verificationQueueName } from './queues.js'; diff --git a/apps/bot/src/modules/utility/command-definitions.ts b/apps/bot/src/modules/utility/command-definitions.ts index 97cef45..34369db 100644 --- a/apps/bot/src/modules/utility/command-definitions.ts +++ b/apps/bot/src/modules/utility/command-definitions.ts @@ -1,7 +1,6 @@ import { SlashCommandBuilder, type SlashCommandAttachmentOption, - type SlashCommandBooleanOption, type SlashCommandChannelOption, type SlashCommandRoleOption, type SlashCommandStringOption, diff --git a/apps/bot/src/modules/utility/context-menus.ts b/apps/bot/src/modules/utility/context-menus.ts index a66897d..928c3b6 100644 --- a/apps/bot/src/modules/utility/context-menus.ts +++ b/apps/bot/src/modules/utility/context-menus.ts @@ -1,6 +1,6 @@ import { ContextMenuCommandBuilder, ApplicationCommandType } from 'discord.js'; import { t } from '@nexumi/shared'; -import type { BotContext, ContextMenuCommand } from '../../types.js'; +import type { ContextMenuCommand } from '../../types.js'; import { getGuildLocale } from '../../i18n.js'; async function translateText(text: string, targetLang: string): Promise { diff --git a/apps/bot/src/modules/utility/snipe.ts b/apps/bot/src/modules/utility/snipe.ts index 112be8f..74108e3 100644 --- a/apps/bot/src/modules/utility/snipe.ts +++ b/apps/bot/src/modules/utility/snipe.ts @@ -1,7 +1,6 @@ import { EmbedBuilder, type Message, type PartialMessage } from 'discord.js'; import { t, tf } from '@nexumi/shared'; import type { BotContext } from '../../types.js'; -import { getGuildLocale } from '../../i18n.js'; const SNIPE_TTL_SECONDS = 3600; const DELETE_KEY_PREFIX = 'util:snipe:delete:'; diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index e3a9356..2796135 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -135,48 +135,53 @@ Dieses Dokument hält den aktuellen Implementierungsstand fest. Es wird bei jede - Welcome-/Logging-Konfiguration erfolgt in Phase 2 über die Datenbank; die WebUI-Editoren kommen in Phase 7. - Captcha-Seite läuft am Bot-Health-Port (`HEALTH_PORT`); in Produktion muss `PUBLIC_BASE_URL` darauf zeigen oder über Reverse-Proxy geroutet werden. -## Phase 3 – Leveling, Economy, Utility, Fun (Status: in Arbeit) +## Phase 3 – Leveling, Economy, Utility, Fun (Status: implementiert, manuelle Tests ausstehend) -### Utility (`apps/bot/src/modules/utility/`) – implementiert +### Abgeschlossen -- Info-Commands: `/userinfo`, `/serverinfo`, `/roleinfo`, `/channelinfo`, `/avatar`, `/banner` -- `/poll create` mit Buttons, DB (`Poll`/`PollVote`), optionaler Ablauf via BullMQ (`pollClose`) -- `/remindme`, `/reminders list|delete` mit BullMQ (`reminderSend`) -- `/afk set` mit Auto-Clear und Mention-Antwort -- `/emoji add|remove|steal`, `/sticker add` -- `/timestamp`, Context-Menu **Translate** (MyMemory API) -- `/snipe`, `/editsnipe` (Redis, TTL 1 h, Datenschutz-Hinweis) -- `/embed builder` (Modal) -- Queue: `reminderQueue` in `queues.ts`, Worker in `jobs.ts` -- i18n-Keys in `@nexumi/shared` (de/en) + `command-locales.ts` -- **Manuelles Wiring durch Parent noch nötig** (siehe Modul-README / Agent-Anweisung): Commands, Context-Menüs, Events, Interaction-Handler in `index.ts`/`commands.ts` +- Prisma-Migration `20260722150000_phase3_modules`: + - `LevelingConfig`, `MemberLevel`, `LevelReward` + - `EconomyConfig`, `MemberEconomy`, `ShopItem`, `InventoryItem` + - `Poll`, `PollVote`, `Reminder`, `AfkStatus` +- Shared-Helfer `packages/shared/src/phase3.ts` + Tests +- Commands und Events in Root verdrahtet (`commands.ts`, `index.ts`) -### Fun & Games (`apps/bot/src/modules/fun/`) – implementiert +### Leveling (`apps/bot/src/modules/leveling/`) -- Einfache Commands: `/8ball`, `/dice`, `/flip` (nicht `/coinflip` – Economy), `/rps`, `/choose` -- Button-Spiele: `/trivia`, `/tictactoe`, `/connect4`, `/hangman` (Redis-Spielzustand, TTL) -- Medien: `/meme`, `/cat`, `/dog` (API-Fetch; abschaltbar via Redis `fun:config:{guildId}` → `{mediaEnabled}`) -- Exports für Wiring: `funCommands`, `isFunButton`, `handleFunButton` aus `index.ts` -- i18n-Keys + Command-Locales in `@nexumi/shared` (de/en) -- **Manuelles Wiring durch Parent noch nötig**: `funCommands` in `commands.ts`, `isFunButton`/`handleFunButton` in `index.ts` +- `/rank` (Canvas-Rankkarte), `/leaderboard`, `/xp give|remove|reset` +- Text-XP (Cooldown Redis) und Voice-XP +- Multiplikatoren, No-XP-Kanäle/-Rollen, Level-Rewards, Level-Up CHANNEL/DM/OFF -### Manuelle Discord-Tests Fun (offen) +### Economy (`apps/bot/src/modules/economy/`) -- `/flip` vs. Economy `/coinflip` (kein Namenskonflikt) -- Trivia: nur Initiator kann antworten, Ablauf nach 2 min -- Tic-Tac-Toe / Connect 4: Züge nur für Teilnehmer, ephemerale Fehler -- Hangman: Buchstaben-Buttons, Gewinn/Verlust -- `/meme`, `/cat`, `/dog`; Medien deaktivieren via Redis-Config testen +- `/balance`, `/daily`, `/weekly`, `/work`, `/pay` +- `/gamble`, `/slots`, `/blackjack` (Buttons), `/coinflip` (Wette) +- `/shop view|buy`, `/inventory`, `/eco leaderboard|give|remove|reset` +- Währung pro Server in `EconomyConfig` -### Manuelle Discord-Tests Utility (offen) +### Utility (`apps/bot/src/modules/utility/`) -- Poll mit/ohne Dauer, Mehrfachauswahl, anonym -- Reminder einmalig und mit Cron -- AFK setzen, erwähnen, zurückschreiben -- Snipe nach Löschen/Bearbeiten -- Embed-Builder-Modal -- Translate Context-Menu auf Nachricht +- `/userinfo`, `/serverinfo`, `/roleinfo`, `/channelinfo`, `/avatar`, `/banner` +- `/poll create` (Buttons, DB, Ablauf via BullMQ) +- `/remindme`, `/reminders list|delete` (BullMQ) +- `/afk set`, `/emoji add|remove|steal`, `/sticker add`, `/timestamp` +- Context-Menu **Translate**, `/snipe`, `/editsnipe`, `/embed builder` + +### Fun (`apps/bot/src/modules/fun/`) + +- `/8ball`, `/dice`, `/flip` (freier Münzwurf; Economy behält `/coinflip`) +- `/rps`, `/choose`, `/trivia`, `/tictactoe`, `/connect4`, `/hangman` +- `/meme`, `/cat`, `/dog` (abschaltbar via Redis `fun:config:{guildId}`) + +### Manuelle Discord-Tests (offen) + +- Leveling: Nachrichten/Voice → XP, `/rank`, Level-Up, `/xp` +- Economy: Daily/Work, Shop, Blackjack-Buttons, `/eco give` +- Utility: Poll, Reminder, AFK, Snipe, Embed, Translate +- Fun: `/flip` vs `/coinflip`, Spiele, Medien-APIs +- Stack neu bauen: `docker compose up -d --build` (Migration anwenden) ## Nächster geplanter Schritt -- Utility- und Fun-Wiring abschließen, dann Leveling/Economy/Fun manuell testen; Phase-3-Freigabe nach grünem Build/Lint/Tests. +- Manuelle Phase-3-Tests, dann nach Freigabe Phase 4 (Giveaways, Tickets, Reaction Roles, Custom Commands, Starboard, Suggestions, Geburtstage, Temp-Voice). + diff --git a/packages/shared/src/phase3.test.ts b/packages/shared/src/phase3.test.ts new file mode 100644 index 0000000..fb50057 --- /dev/null +++ b/packages/shared/src/phase3.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { levelFromXp, progressInLevel, totalXpForLevel, xpForNextLevel } from './phase3.js'; + +describe('phase3 xp helpers', () => { + it('computes xp for next level', () => { + expect(xpForNextLevel(0)).toBe(100); + expect(xpForNextLevel(1)).toBe(155); + }); + + it('derives level from xp', () => { + expect(levelFromXp(0)).toBe(0); + expect(levelFromXp(100)).toBe(1); + expect(levelFromXp(totalXpForLevel(5))).toBe(5); + }); + + it('reports progress in current level', () => { + const progress = progressInLevel(totalXpForLevel(2) + 10); + expect(progress.level).toBe(2); + expect(progress.intoLevel).toBe(10); + expect(progress.needed).toBe(xpForNextLevel(2)); + }); +});