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.
This commit is contained in:
smueller
2026-07-22 12:56:00 +02:00
parent fa5910ec43
commit 58623bd1d3
8 changed files with 140 additions and 43 deletions

View File

@@ -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 { t } from '@nexumi/shared';
import { env } from './env.js'; import { env } from './env.js';
import { logger } from './logger.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 { automodCommands } from './modules/automod/commands.js';
import { welcomeCommands } from './modules/welcome/commands.js'; import { welcomeCommands } from './modules/welcome/commands.js';
import { verificationCommands } from './modules/verification/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'; import type { BotContext, SlashCommand } from './types.js';
const commands: SlashCommand[] = [ const commands: SlashCommand[] = [
...moderationCommands, ...moderationCommands,
...automodCommands, ...automodCommands,
...welcomeCommands, ...welcomeCommands,
...verificationCommands ...verificationCommands,
...levelingCommands,
...economyCommands,
...utilityCommands,
...funCommands
]; ];
const map = new Map(commands.map((c) => [c.data.name, c])); 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() { export async function registerCommands() {
const rest = new REST({ version: '10' }).setToken(env.BOT_TOKEN); const rest = new REST({ version: '10' }).setToken(env.BOT_TOKEN);
await rest.put(Routes.applicationCommands(env.BOT_CLIENT_ID), { 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) { export async function routeCommand(interaction: ChatInputCommandInteraction, context: BotContext) {
@@ -34,3 +54,16 @@ export async function routeCommand(interaction: ChatInputCommandInteraction, con
} }
await command.execute(interaction, context); 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);
}

View File

@@ -10,7 +10,7 @@ import { env } from './env.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
import { prisma } from './db.js'; import { prisma } from './db.js';
import { redis } from './redis.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 { ensureRecurringJobs, startWorkers } from './jobs.js';
import type { BotContext } from './types.js'; import type { BotContext } from './types.js';
import { startHealthServer } from './health.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 { registerVerificationEvents } from './modules/verification/events.js';
import { handleVerificationButton } from './modules/verification/handlers.js'; import { handleVerificationButton } from './modules/verification/handlers.js';
import { isVerificationButton } from './modules/verification/service.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'); const isShard = process.argv.includes('--shard');
@@ -59,6 +72,8 @@ if (!isShard) {
registerLoggingEvents(context); registerLoggingEvents(context);
registerWelcomeEvents(context); registerWelcomeEvents(context);
registerVerificationEvents(context); registerVerificationEvents(context);
registerLevelingEvents(context);
registerUtilityEvents(context);
client.on(Events.ClientReady, async () => { client.on(Events.ClientReady, async () => {
logger.info({ tag: client.user?.tag }, 'Bot ready'); logger.info({ tag: client.user?.tag }, 'Bot ready');
@@ -80,6 +95,11 @@ if (!isShard) {
return; return;
} }
if (interaction.isMessageContextMenuCommand()) {
await routeContextMenu(interaction, context);
return;
}
if (interaction.isButton() && isModerationConfirmation(interaction.customId)) { if (interaction.isButton() && isModerationConfirmation(interaction.customId)) {
await handleModerationConfirmation(interaction, context); await handleModerationConfirmation(interaction, context);
return; return;
@@ -89,6 +109,26 @@ if (!isShard) {
await handleVerificationButton(interaction, context); await handleVerificationButton(interaction, context);
return; 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) { } catch (error) {
logger.error({ error }, 'Interaction handling failed'); logger.error({ error }, 'Interaction handling failed');
const locale = await getGuildLocale(context.prisma, interaction.guildId); const locale = await getGuildLocale(context.prisma, interaction.guildId);

View File

@@ -17,7 +17,6 @@ import {
backupQueue, backupQueue,
backupQueueName, backupQueueName,
moderationQueueName, moderationQueueName,
reminderQueue,
reminderQueueName, reminderQueueName,
verificationQueueName verificationQueueName
} from './queues.js'; } from './queues.js';

View File

@@ -1,7 +1,6 @@
import { import {
SlashCommandBuilder, SlashCommandBuilder,
type SlashCommandAttachmentOption, type SlashCommandAttachmentOption,
type SlashCommandBooleanOption,
type SlashCommandChannelOption, type SlashCommandChannelOption,
type SlashCommandRoleOption, type SlashCommandRoleOption,
type SlashCommandStringOption, type SlashCommandStringOption,

View File

@@ -1,6 +1,6 @@
import { ContextMenuCommandBuilder, ApplicationCommandType } from 'discord.js'; import { ContextMenuCommandBuilder, ApplicationCommandType } from 'discord.js';
import { t } from '@nexumi/shared'; import { t } from '@nexumi/shared';
import type { BotContext, ContextMenuCommand } from '../../types.js'; import type { ContextMenuCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js'; import { getGuildLocale } from '../../i18n.js';
async function translateText(text: string, targetLang: string): Promise<string | null> { async function translateText(text: string, targetLang: string): Promise<string | null> {

View File

@@ -1,7 +1,6 @@
import { EmbedBuilder, type Message, type PartialMessage } from 'discord.js'; import { EmbedBuilder, type Message, type PartialMessage } from 'discord.js';
import { t, tf } from '@nexumi/shared'; import { t, tf } from '@nexumi/shared';
import type { BotContext } from '../../types.js'; import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
const SNIPE_TTL_SECONDS = 3600; const SNIPE_TTL_SECONDS = 3600;
const DELETE_KEY_PREFIX = 'util:snipe:delete:'; const DELETE_KEY_PREFIX = 'util:snipe:delete:';

View File

@@ -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. - 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. - 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` - Prisma-Migration `20260722150000_phase3_modules`:
- `/poll create` mit Buttons, DB (`Poll`/`PollVote`), optionaler Ablauf via BullMQ (`pollClose`) - `LevelingConfig`, `MemberLevel`, `LevelReward`
- `/remindme`, `/reminders list|delete` mit BullMQ (`reminderSend`) - `EconomyConfig`, `MemberEconomy`, `ShopItem`, `InventoryItem`
- `/afk set` mit Auto-Clear und Mention-Antwort - `Poll`, `PollVote`, `Reminder`, `AfkStatus`
- `/emoji add|remove|steal`, `/sticker add` - Shared-Helfer `packages/shared/src/phase3.ts` + Tests
- `/timestamp`, Context-Menu **Translate** (MyMemory API) - Commands und Events in Root verdrahtet (`commands.ts`, `index.ts`)
- `/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`
### Fun & Games (`apps/bot/src/modules/fun/`) implementiert ### Leveling (`apps/bot/src/modules/leveling/`)
- Einfache Commands: `/8ball`, `/dice`, `/flip` (nicht `/coinflip` Economy), `/rps`, `/choose` - `/rank` (Canvas-Rankkarte), `/leaderboard`, `/xp give|remove|reset`
- Button-Spiele: `/trivia`, `/tictactoe`, `/connect4`, `/hangman` (Redis-Spielzustand, TTL) - Text-XP (Cooldown Redis) und Voice-XP
- Medien: `/meme`, `/cat`, `/dog` (API-Fetch; abschaltbar via Redis `fun:config:{guildId}``{mediaEnabled}`) - Multiplikatoren, No-XP-Kanäle/-Rollen, Level-Rewards, Level-Up CHANNEL/DM/OFF
- 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`
### Manuelle Discord-Tests Fun (offen) ### Economy (`apps/bot/src/modules/economy/`)
- `/flip` vs. Economy `/coinflip` (kein Namenskonflikt) - `/balance`, `/daily`, `/weekly`, `/work`, `/pay`
- Trivia: nur Initiator kann antworten, Ablauf nach 2 min - `/gamble`, `/slots`, `/blackjack` (Buttons), `/coinflip` (Wette)
- Tic-Tac-Toe / Connect 4: Züge nur für Teilnehmer, ephemerale Fehler - `/shop view|buy`, `/inventory`, `/eco leaderboard|give|remove|reset`
- Hangman: Buchstaben-Buttons, Gewinn/Verlust - Währung pro Server in `EconomyConfig`
- `/meme`, `/cat`, `/dog`; Medien deaktivieren via Redis-Config testen
### Manuelle Discord-Tests Utility (offen) ### Utility (`apps/bot/src/modules/utility/`)
- Poll mit/ohne Dauer, Mehrfachauswahl, anonym - `/userinfo`, `/serverinfo`, `/roleinfo`, `/channelinfo`, `/avatar`, `/banner`
- Reminder einmalig und mit Cron - `/poll create` (Buttons, DB, Ablauf via BullMQ)
- AFK setzen, erwähnen, zurückschreiben - `/remindme`, `/reminders list|delete` (BullMQ)
- Snipe nach Löschen/Bearbeiten - `/afk set`, `/emoji add|remove|steal`, `/sticker add`, `/timestamp`
- Embed-Builder-Modal - Context-Menu **Translate**, `/snipe`, `/editsnipe`, `/embed builder`
- Translate Context-Menu auf Nachricht
### 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 ## 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).

View File

@@ -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));
});
});